doc-compass / doccompass /router.py
ssg1's picture
Upload the Doc Compass app bundle
967b9df verified
Raw History Blame Contribute Delete
4.23 kB
"""Routers: text in, one probability per label out (columns in LABELS order)."""
from pathlib import Path
import joblib
import numpy as np
from sklearn.feature_extraction.text import ENGLISH_STOP_WORDS, TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from .labels import LABEL2ID, LABELS
_FILLER = ENGLISH_STOP_WORDS | {"feel", "feeling", "feels", "felt", "like", "really", "just", "got", "getting", "having", "week", "weeks", "month", "months", "day", "days"}
class TfidfRouter:
"""The trained-from-scratch router: TF-IDF features and logistic regression."""
def __init__(self, C: float = 10.0):
self.pipeline = make_pipeline(
TfidfVectorizer(ngram_range=(1, 2), min_df=2, sublinear_tf=True, strip_accents="unicode"),
LogisticRegression(C=C, max_iter=2000, class_weight="balanced"),
)
def fit(self, texts, labels, sample_weight=None) -> "TfidfRouter":
ids = [LABEL2ID[label] for label in labels]
self.pipeline.fit(list(texts), ids, logisticregression__sample_weight=sample_weight)
return self
def predict_proba(self, texts) -> np.ndarray:
"""Probabilities with one column per label, zero for any label unseen in training."""
seen = self.pipeline.predict_proba(list(texts))
proba = np.zeros((seen.shape[0], len(LABELS)))
proba[:, self.pipeline.classes_] = seen
return proba
def evidence(self, text: str, label: str, k: int = 3) -> list[str]:
"""The words in the text that pushed hardest toward this label."""
vectorizer, model = self.pipeline[0], self.pipeline[-1]
classes = list(model.classes_)
if LABEL2ID[label] not in classes:
return []
weighted = vectorizer.transform([text]).multiply(model.coef_[classes.index(LABEL2ID[label])]).tocoo()
names = vectorizer.get_feature_names_out()
words: list[str] = []
for i in np.argsort(-weighted.data):
if weighted.data[i] <= 0 or len(words) == k:
break
# Keep the meaningful words of the feature: "my ears" -> "ears"; skip pure filler.
content = " ".join(w for w in names[weighted.col[i]].split() if len(w) > 2 and w not in _FILLER)
if content and not any(content in seen or seen in content for seen in words):
words.append(content)
return words
def save(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
joblib.dump(self.pipeline, path)
@classmethod
def load(cls, path: Path) -> "TfidfRouter":
router = cls()
router.pipeline = joblib.load(path)
return router
class EncoderRouter:
"""A fine-tuned encoder router, loaded from a folder written by scripts/train_encoder.py."""
def __init__(self, folder: Path, max_length: int = 512, batch_size: int = 32):
import torch # imported here so the from-scratch router works without PyTorch installed
from transformers import AutoModelForSequenceClassification, AutoTokenizer
self.torch = torch
self.device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
self.tokenizer = AutoTokenizer.from_pretrained(folder)
self.model = AutoModelForSequenceClassification.from_pretrained(folder).to(self.device).eval()
if [self.model.config.id2label[i] for i in range(len(LABELS))] != LABELS:
raise ValueError(f"{folder} was trained with a different label list.")
self.max_length, self.batch_size = max_length, batch_size
def predict_proba(self, texts) -> np.ndarray:
texts = list(texts)
chunks = []
with self.torch.inference_mode():
for start in range(0, len(texts), self.batch_size):
batch = self.tokenizer(texts[start:start + self.batch_size], truncation=True, max_length=self.max_length,
padding=True, return_tensors="pt").to(self.device)
chunks.append(self.torch.softmax(self.model(**batch).logits, dim=-1).float().cpu().numpy())
return np.concatenate(chunks)