Course ES
← back to chapter

Label classifier

Classify text into categories with no training and no inference-time model call

Showcase — Label classifier

Classify text into categories with no training and no inference-time model call beyond an embedding. Each category is a short description, embedded once; an incoming message is embedded and assigned to the nearest description by cosine similarity. Adding a category is one sentence — the "retraining" is instant.

Run

bash bootstrap-secrets.sh              # reads ../../../../.env, writes secrets/
docker compose up --build              # default: PROVIDER=openai

Open http://localhost:3000.

To run against Gemini instead:

PROVIDER=gemini docker compose up --build

What's where

  • backend/ai_openai.py / backend/ai_gemini.py — the label descriptions (embedded once), and nearest-label assignment by cosine similarity.
  • backend/main.py — identical FastAPI loader; reads PROVIDER and dispatches.
  • frontend/app/page.tsx — message box + predicted label + all scores.

This is a fine baseline classifier. When the categories get subtle or the volume gets large, that's when a fine-tune (later in the course) earns its keep.

Stop

docker compose down

Run locally

Download the project as a ZIP and run it with Docker. Brings up a FastAPI backend + Next.js frontend on localhost:3000.

Download label-classifier.zip

unzip label-classifier.zip
cd label-classifier
bash bootstrap-secrets.sh   # one-time: pulls API keys into ./secrets
docker compose up --build   # default provider: openai
# or:  PROVIDER=gemini docker compose up --build

Type some input, pick a provider, and run the same code shown in Source against the live API. Sign-in required.


  

The same modules the Run button hits. The whole project (frontend, Dockerfile, compose) is in the ZIP under README.

backend/ai_openai.py

"""Showcase 2 (OpenAI): zero-shot classification with embeddings.

No training, no fine-tuning, no model call at inference beyond embedding. Embed
a set of label descriptions once, embed the incoming text, and assign whichever
label sits closest in cosine similarity. Add a new category by writing a
sentence — that's the whole "retraining" step.
"""
import math

from openai import OpenAI

_client = OpenAI()

_MODEL = "text-embedding-3-small"

# Label -> a short description. Richer descriptions embed to better anchors than
# bare one-word labels, so the nearest-neighbor decision is sharper.
_LABELS = {
    "billing": "A question or problem about payment, charges, invoices, or refunds.",
    "technical support": "Something is broken, erroring, crashing, or not working.",
    "feature request": "Asking for a new capability or an improvement.",
    "shipping": "About delivery, tracking, or the status of an order in transit.",
    "account access": "Login trouble, passwords, two-factor, or being locked out.",
    "praise": "Positive feedback or a compliment about the product or service.",
}

_names = list(_LABELS.keys())
_label_vecs: list[list[float]] | None = None


def _embed(texts: list[str]) -> list[list[float]]:
    return [item.embedding for item in _client.embeddings.create(model=_MODEL, input=texts).data]


def _cosine(a: list[float], b: list[float]) -> float:
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(y * y for y in b))
    return dot / (na * nb) if na and nb else 0.0


def _labels() -> list[list[float]]:
    global _label_vecs
    if _label_vecs is None:  # embed the label descriptions once
        _label_vecs = _embed(list(_LABELS.values()))
    return _label_vecs


def run(text: str) -> str:
    tv = _embed([text.strip()])[0]
    scored = sorted(
        ((_cosine(tv, lv), name) for lv, name in zip(_labels(), _names)),
        reverse=True,
    )
    best_score, best_name = scored[0]
    rows = "\n".join(f"  {score:.3f}  {name}" for score, name in scored)
    return f"Predicted category: {best_name}  (cosine {best_score:.3f})\n\nAll labels:\n{rows}"

backend/ai_gemini.py

"""Showcase 2 (Gemini): zero-shot classification with embeddings.

Same nearest-label idea, Gemini's embedding model.
"""
import math
import os

from google import genai

_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])

_MODEL = "gemini-embedding-001"

_LABELS = {
    "billing": "A question or problem about payment, charges, invoices, or refunds.",
    "technical support": "Something is broken, erroring, crashing, or not working.",
    "feature request": "Asking for a new capability or an improvement.",
    "shipping": "About delivery, tracking, or the status of an order in transit.",
    "account access": "Login trouble, passwords, two-factor, or being locked out.",
    "praise": "Positive feedback or a compliment about the product or service.",
}

_names = list(_LABELS.keys())
_label_vecs: list[list[float]] | None = None


def _embed(texts: list[str]) -> list[list[float]]:
    return [e.values for e in _client.models.embed_content(model=_MODEL, contents=texts).embeddings]


def _cosine(a: list[float], b: list[float]) -> float:
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(y * y for y in b))
    return dot / (na * nb) if na and nb else 0.0


def _labels() -> list[list[float]]:
    global _label_vecs
    if _label_vecs is None:
        _label_vecs = _embed(list(_LABELS.values()))
    return _label_vecs


def run(text: str) -> str:
    tv = _embed([text.strip()])[0]
    scored = sorted(
        ((_cosine(tv, lv), name) for lv, name in zip(_labels(), _names)),
        reverse=True,
    )
    best_score, best_name = scored[0]
    rows = "\n".join(f"  {score:.3f}  {name}" for score, name in scored)
    return f"Predicted category: {best_name}  (cosine {best_score:.3f})\n\nAll labels:\n{rows}"

Project files

  • .gitignore
  • README.es.md
  • README.md
  • backend/Dockerfile
  • backend/ai_gemini.py
  • backend/ai_openai.py
  • backend/main.py
  • backend/requirements.txt
  • bootstrap-secrets.sh
  • docker-compose.yml
  • frontend/Dockerfile
  • frontend/app/layout.tsx
  • frontend/app/page.tsx
  • frontend/next.config.ts
  • frontend/package.json
  • frontend/tsconfig.json