Text Classification
Transformers
Safetensors
English
distilbert
document-classification
document-ai
pii-detection
redaction
text-embeddings-inference
Instructions to use FahrenheitResearch/FR-Docs-v1 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use FahrenheitResearch/FR-Docs-v1 with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="FahrenheitResearch/FR-Docs-v1")# Load model directly from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer = AutoTokenizer.from_pretrained("FahrenheitResearch/FR-Docs-v1") model = AutoModelForSequenceClassification.from_pretrained("FahrenheitResearch/FR-Docs-v1") - Notebooks
- Google Colab
- Kaggle
| """FR-Docs classifier. | |
| Two interchangeable engines behind one interface: | |
| - EmbeddingClassifier (v0, ships today): embeds the document and the 30 | |
| category anchors with a small sentence-transformer and picks the nearest | |
| anchor by cosine similarity. No training required. | |
| - FineTunedClassifier (v1): loads the fine-tuned ModernBERT checkpoint | |
| produced by training/train.py. Drop-in replacement — same predict() API — | |
| so the service code never changes when you swap engines. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass, asdict | |
| import numpy as np | |
| from .taxonomy import Category, load_taxonomy | |
| # Confidence below which we return the parent group instead of the leaf | |
| LEAF_CONFIDENCE_FLOOR = 0.35 | |
| # Margin over runner-up below which we flag the prediction as ambiguous | |
| AMBIGUITY_MARGIN = 0.03 | |
| EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2" | |
| class Prediction: | |
| category_id: str | |
| category_label: str | |
| group_id: str | |
| group_label: str | |
| confidence: float | |
| ambiguous: bool | |
| top3: list[dict] | |
| engine: str | |
| def to_dict(self) -> dict: | |
| return asdict(self) | |
| class EmbeddingClassifier: | |
| """Zero-training anchor-similarity classifier.""" | |
| def __init__(self, model_name: str = EMBED_MODEL): | |
| from sentence_transformers import SentenceTransformer | |
| self.model = SentenceTransformer(model_name) | |
| self.categories: list[Category] = load_taxonomy() | |
| anchors = [f"{c.label}. {c.anchor}" for c in self.categories] | |
| self.anchor_vecs = self.model.encode( | |
| anchors, normalize_embeddings=True, show_progress_bar=False | |
| ) | |
| def predict(self, text: str) -> Prediction: | |
| if not text.strip(): | |
| other = next(c for c in self.categories if c.id == "other") | |
| return Prediction( | |
| category_id=other.id, category_label=other.label, | |
| group_id=other.group_id, group_label=other.group_label, | |
| confidence=0.0, ambiguous=True, top3=[], engine="embedding-v0", | |
| ) | |
| doc_vec = self.model.encode( | |
| [text], normalize_embeddings=True, show_progress_bar=False | |
| )[0] | |
| sims = self.anchor_vecs @ doc_vec | |
| # Softmax over similarities (temperature sharpens the raw cosine range) | |
| probs = np.exp(sims / 0.05) | |
| probs /= probs.sum() | |
| order = np.argsort(probs)[::-1] | |
| best, second = order[0], order[1] | |
| cat = self.categories[best] | |
| confidence = float(probs[best]) | |
| ambiguous = (confidence - float(probs[second])) < AMBIGUITY_MARGIN | |
| top3 = [ | |
| { | |
| "category_id": self.categories[i].id, | |
| "label": self.categories[i].label, | |
| "confidence": round(float(probs[i]), 4), | |
| } | |
| for i in order[:3] | |
| ] | |
| return Prediction( | |
| category_id=cat.id, | |
| category_label=cat.label, | |
| group_id=cat.group_id, | |
| group_label=cat.group_label, | |
| confidence=round(confidence, 4), | |
| ambiguous=ambiguous or confidence < LEAF_CONFIDENCE_FLOOR, | |
| top3=top3, | |
| engine="embedding-v0", | |
| ) | |
| class LiteClassifier: | |
| """Dependency-free fallback: TF-IDF cosine similarity against anchors. | |
| Used automatically when sentence-transformers is not installed (e.g. | |
| restricted or edge environments). Lower accuracy than the embedding | |
| engine; same interface. | |
| """ | |
| _token_re = None | |
| def __init__(self): | |
| import math | |
| import re | |
| self.math = math | |
| self._token_re = re.compile(r"[a-z]{2,}") | |
| self.categories: list[Category] = load_taxonomy() | |
| docs = [self._tokens(f"{c.label} {c.anchor}") for c in self.categories] | |
| # idf over anchor corpus | |
| df: dict[str, int] = {} | |
| for toks in docs: | |
| for t in set(toks): | |
| df[t] = df.get(t, 0) + 1 | |
| n = len(docs) | |
| self.idf = {t: math.log((n + 1) / (d + 1)) + 1 for t, d in df.items()} | |
| self.anchor_vecs = [self._vec(toks) for toks in docs] | |
| def _tokens(self, text: str) -> list[str]: | |
| return self._token_re.findall(text.lower()) | |
| def _vec(self, tokens: list[str]) -> dict[str, float]: | |
| tf: dict[str, float] = {} | |
| for t in tokens: | |
| tf[t] = tf.get(t, 0) + 1 | |
| vec = {t: f * self.idf.get(t, 1.0) for t, f in tf.items()} | |
| norm = self.math.sqrt(sum(v * v for v in vec.values())) or 1.0 | |
| return {t: v / norm for t, v in vec.items()} | |
| def predict(self, text: str) -> Prediction: | |
| if not text.strip(): | |
| other = next(c for c in self.categories if c.id == "other") | |
| return Prediction( | |
| category_id=other.id, category_label=other.label, | |
| group_id=other.group_id, group_label=other.group_label, | |
| confidence=0.0, ambiguous=True, top3=[], engine="lite-tfidf", | |
| ) | |
| doc_vec = self._vec(self._tokens(text)) | |
| sims = np.array([ | |
| sum(doc_vec.get(t, 0.0) * v for t, v in anchor.items()) | |
| for anchor in self.anchor_vecs | |
| ]) | |
| probs = np.exp(sims / 0.05) | |
| probs /= probs.sum() | |
| order = np.argsort(probs)[::-1] | |
| best, second = order[0], order[1] | |
| cat = self.categories[best] | |
| confidence = float(probs[best]) | |
| ambiguous = (confidence - float(probs[second])) < AMBIGUITY_MARGIN | |
| top3 = [ | |
| { | |
| "category_id": self.categories[i].id, | |
| "label": self.categories[i].label, | |
| "confidence": round(float(probs[i]), 4), | |
| } | |
| for i in order[:3] | |
| ] | |
| return Prediction( | |
| category_id=cat.id, | |
| category_label=cat.label, | |
| group_id=cat.group_id, | |
| group_label=cat.group_label, | |
| confidence=round(confidence, 4), | |
| ambiguous=ambiguous or confidence < LEAF_CONFIDENCE_FLOOR, | |
| top3=top3, | |
| engine="lite-tfidf", | |
| ) | |
| class SklearnClassifier: | |
| """v0.5: trained TF-IDF + LogisticRegression checkpoint (joblib).""" | |
| def __init__(self, model_path: str): | |
| import joblib | |
| self.pipeline = joblib.load(model_path) | |
| self.categories = {c.id: c for c in load_taxonomy()} | |
| self.class_order = list(self.pipeline.classes_) | |
| def predict(self, text: str) -> Prediction: | |
| if not text.strip(): | |
| other = self.categories["other"] | |
| return Prediction( | |
| category_id=other.id, category_label=other.label, | |
| group_id=other.group_id, group_label=other.group_label, | |
| confidence=0.0, ambiguous=True, top3=[], engine="sklearn-v0.5", | |
| ) | |
| probs = self.pipeline.predict_proba([text])[0] | |
| order = np.argsort(probs)[::-1] | |
| best, second = order[0], order[1] | |
| cat = self.categories[self.class_order[best]] | |
| confidence = float(probs[best]) | |
| ambiguous = (confidence - float(probs[second])) < AMBIGUITY_MARGIN | |
| top3 = [ | |
| { | |
| "category_id": self.class_order[i], | |
| "label": self.categories[self.class_order[i]].label, | |
| "confidence": round(float(probs[i]), 4), | |
| } | |
| for i in order[:3] | |
| ] | |
| return Prediction( | |
| category_id=cat.id, | |
| category_label=cat.label, | |
| group_id=cat.group_id, | |
| group_label=cat.group_label, | |
| confidence=round(confidence, 4), | |
| ambiguous=ambiguous or confidence < LEAF_CONFIDENCE_FLOOR, | |
| top3=top3, | |
| engine="sklearn-v0.5", | |
| ) | |
| class FineTunedClassifier: | |
| """Loads the ModernBERT checkpoint trained with training/train.py.""" | |
| def __init__(self, checkpoint_dir: str): | |
| import torch | |
| from transformers import AutoModelForSequenceClassification, AutoTokenizer | |
| self.torch = torch | |
| self.tokenizer = AutoTokenizer.from_pretrained(checkpoint_dir) | |
| self.model = AutoModelForSequenceClassification.from_pretrained(checkpoint_dir) | |
| self.model.eval() | |
| self.categories = {c.id: c for c in load_taxonomy()} | |
| # Respect the architecture's real context limit (512 for DistilBERT, | |
| # 8192 for ModernBERT); never exceed what the checkpoint supports. | |
| model_limit = getattr(self.model.config, "max_position_embeddings", 512) | |
| tok_limit = self.tokenizer.model_max_length | |
| if tok_limit is None or tok_limit > 100_000: # some tokenizers report a sentinel | |
| tok_limit = model_limit | |
| self.max_length = min(model_limit, tok_limit, 4096) | |
| def predict(self, text: str) -> Prediction: | |
| inputs = self.tokenizer( | |
| text, truncation=True, max_length=self.max_length, return_tensors="pt" | |
| ) | |
| with self.torch.no_grad(): | |
| logits = self.model(**inputs).logits[0] | |
| probs = logits.softmax(-1) | |
| order = probs.argsort(descending=True) | |
| best = order[0].item() | |
| cat_id = self.model.config.id2label[best] | |
| cat = self.categories[cat_id] | |
| confidence = float(probs[best]) | |
| ambiguous = (confidence - float(probs[order[1]])) < AMBIGUITY_MARGIN | |
| top3 = [ | |
| { | |
| "category_id": self.model.config.id2label[i.item()], | |
| "label": self.categories[self.model.config.id2label[i.item()]].label, | |
| "confidence": round(float(probs[i]), 4), | |
| } | |
| for i in order[:3] | |
| ] | |
| return Prediction( | |
| category_id=cat.id, | |
| category_label=cat.label, | |
| group_id=cat.group_id, | |
| group_label=cat.group_label, | |
| confidence=round(confidence, 4), | |
| ambiguous=ambiguous or confidence < LEAF_CONFIDENCE_FLOOR, | |
| top3=top3, | |
| engine="modernbert-v1", | |
| ) | |
| def get_classifier(checkpoint_dir: str | None = None, sklearn_path: str | None = None): | |
| """Factory, best available first: | |
| ModernBERT checkpoint > sklearn checkpoint > embedding engine > lite fallback. | |
| """ | |
| import os | |
| from pathlib import Path | |
| if checkpoint_dir: | |
| return FineTunedClassifier(checkpoint_dir) | |
| sklearn_path = sklearn_path or os.environ.get("FR_DOCS_SKLEARN") | |
| if not sklearn_path: | |
| default = Path(__file__).parent.parent / "checkpoints" / "fr-docs-sklearn.joblib" | |
| if default.exists(): | |
| sklearn_path = str(default) | |
| if sklearn_path: | |
| return SklearnClassifier(sklearn_path) | |
| try: | |
| return EmbeddingClassifier() | |
| except ImportError: | |
| return LiteClassifier() | |