Torres1980's picture
Add transaction classifier model, model card, and training code
8c5accf verified
Raw History Blame Contribute Delete
4.69 kB
"""
Train the alternatives & insurance transaction classifier.
Model: word (1-2 gram) + character (2-5 gram) TF-IDF features into a multinomial
logistic regression. Character n-grams handle abbreviations ("DIST ROC", "MGMT FEE",
"PREM PMT") and typos; word n-grams carry phrase meaning. Predicted probabilities
drive a confidence threshold: items above it are classified straight through, items
below it go to a person.
Two rounds are trained to show the review loop:
v1 - training data only
v2 - training data + 180 items labeled by reviewers from v1's exception queue
The published model.skops is v2.
Run: python data/generate.py && python train.py
"""
import json
import zipfile
from pathlib import Path
import numpy as np
import pandas as pd
import skops.io as sio
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, classification_report, f1_score
from sklearn.pipeline import FeatureUnion, Pipeline
ROOT = Path(__file__).parent
SEED = 42
TARGET_PRECISION = 0.99
def build_pipeline():
features = FeatureUnion([
("word", TfidfVectorizer(analyzer="word", ngram_range=(1, 2), lowercase=True, sublinear_tf=True,
token_pattern=r"(?u)\b[a-zA-Z][a-zA-Z&/]+\b", min_df=2)),
("char", TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 5), lowercase=True, sublinear_tf=True,
min_df=3, max_features=30000)),
])
return Pipeline([("features", features),
("clf", LogisticRegression(C=8.0, max_iter=3000, random_state=SEED))])
def straight_through(proba, y_true, classes, target=TARGET_PRECISION):
"""Lowest confidence threshold whose auto-classified items meet the target precision."""
conf, pred = proba.max(axis=1), classes[proba.argmax(axis=1)]
for t in np.round(np.arange(0.30, 1.0, 0.01), 2):
m = conf >= t
if m.sum() and (pred[m] == y_true[m]).mean() >= target:
return {"threshold": float(t), "auto_rate": round(float(m.mean()), 4),
"auto_precision": round(float((pred[m] == y_true[m]).mean()), 4),
"routed_to_review": round(float(1 - m.mean()), 4)}
return None
def evaluate(model, test):
classes = model.classes_
proba = model.predict_proba(test.text)
pred = classes[proba.argmax(axis=1)]
y = test.label.to_numpy()
out = {"accuracy": round(accuracy_score(y, pred), 4),
"macro_f1": round(f1_score(y, pred, average="macro"), 4),
"straight_through": straight_through(proba, y, classes), "by_condition": {}}
for c in ("unseen_entities", "unseen_phrasing"):
m = (test.condition == c).to_numpy()
out["by_condition"][c] = {"n": int(m.sum()), "accuracy": round(accuracy_score(y[m], pred[m]), 4),
"macro_f1": round(f1_score(y[m], pred[m], average="macro"), 4),
"straight_through": straight_through(proba[m], y[m], classes)}
return out, pred, y
if __name__ == "__main__":
train = pd.read_csv(ROOT / "data" / "train.csv")
test = pd.read_csv(ROOT / "data" / "test.csv")
feedback = pd.read_csv(ROOT / "data" / "review_feedback.csv")
v1 = build_pipeline().fit(train.text, train.label)
m1, _, _ = evaluate(v1, test)
v2 = build_pipeline().fit(pd.concat([train.text, feedback.text]), pd.concat([train.label, feedback.label]))
m2, pred, y = evaluate(v2, test)
report = classification_report(y, pred, digits=4, output_dict=True)
wrong = test[pred != y].assign(predicted=pred[pred != y])
metrics = {
"train_size": len(train), "review_feedback_size": len(feedback), "test_size": len(test),
"num_labels": len(v2.classes_), "target_precision": TARGET_PRECISION,
"v1_train_only": m1, "v2_with_review_feedback": m2,
"v2_per_label_f1": {k: round(v["f1-score"], 4) for k, v in report.items() if k in set(v2.classes_)},
"v2_sample_errors": wrong[["text", "label", "predicted"]].head(12).to_dict("records"),
}
# Drop vocabulary-pruning artifacts; they are only for introspection and bloat the file.
for _, vec in v2.named_steps["features"].transformer_list:
if hasattr(vec, "stop_words_"):
del vec.stop_words_
sio.dump(v2, ROOT / "model.skops", compression=zipfile.ZIP_DEFLATED, compresslevel=9)
(ROOT / "metrics.json").write_text(json.dumps(metrics, indent=2))
print(classification_report(y, pred, digits=4))
for name in ("v1_train_only", "v2_with_review_feedback"):
print(name, json.dumps(metrics[name], indent=1))