ReelShield / backend /train_mpa_classifier.py
abigailkeegan's picture
Deploy ReelShield to Hugging Face Space
8af1084
Raw
History Blame Contribute Delete
4.32 kB
#!/usr/bin/env python3
"""
Train the MPA-rating classifier.
Pulls every well-assessed film with an MPA label out of the cache, fits a
multinomial logistic regression with 80/20 stratified train/test split,
prints metrics, saves the fitted model to data/mpa_classifier.pkl, and
optionally lists films where the model disagrees with the stored MPA
(those are candidates for re-seeding).
Usage:
python backend/train_mpa_classifier.py
python backend/train_mpa_classifier.py --no-save # train + evaluate only
python backend/train_mpa_classifier.py --disagreements # also list misclassified
python backend/train_mpa_classifier.py --top-coefficients 10
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import numpy as np # noqa: E402
from backend.mpa_classifier import ( # noqa: E402
LABEL_NAMES, MODEL_PATH_DEFAULT, disagreement_report,
evaluate, extract_training_data, feature_names, save_model, train_model,
)
DEFAULT_DB = os.environ.get("DB_PATH", "./data/movie_cache.db")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--db", default=DEFAULT_DB)
parser.add_argument("--out", default=MODEL_PATH_DEFAULT)
parser.add_argument("--no-save", action="store_true", help="Skip writing the .pkl")
parser.add_argument("--disagreements", action="store_true",
help="Print films where the trained model disagrees with the stored MPA")
parser.add_argument("--top-coefficients", type=int, default=5,
help="Show top N features per class by absolute coefficient")
parser.add_argument("--random-state", type=int, default=42)
args = parser.parse_args()
print(f"Loading training data from {args.db} …")
data = extract_training_data(args.db)
n = len(data.y)
print(f" {n} labelable films "
f"(family={int(np.sum(data.y == 0))}, "
f"teen={int(np.sum(data.y == 1))}, "
f"adult={int(np.sum(data.y == 2))})")
print(f" Feature space: {data.X.shape[1]}-dimensional")
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
data.X, data.y,
test_size=0.20,
stratify=data.y,
random_state=args.random_state,
)
print(f" Train: {len(y_train)} | Test: {len(y_test)}")
print("\nTraining multinomial logistic regression …")
model = train_model(X_train, y_train, random_state=args.random_state)
print("\n=== Evaluation on 20% held-out test set ===")
metrics = evaluate(model, X_test, y_test)
print(f"Accuracy: {metrics['accuracy']:.3f}")
print(f"Macro F1: {metrics['macro_f1']:.3f}")
print(f"Weighted F1: {metrics['weighted_f1']:.3f}")
print("\nPer-class breakdown:")
print(metrics["report"])
print("Confusion matrix (rows=actual, cols=predicted, order=family/teen/adult):")
for row, name in zip(metrics["confusion"], LABEL_NAMES):
print(f" {name:<7} {row}")
if args.top_coefficients > 0:
print(f"\n=== Top {args.top_coefficients} features per class (|coefficient|) ===")
fns = feature_names()
for class_idx, class_name in enumerate(LABEL_NAMES):
coefs = model.coef_[class_idx]
order = np.argsort(-np.abs(coefs))[: args.top_coefficients]
print(f"\n {class_name}:")
for i in order:
sign = "+" if coefs[i] > 0 else "-"
print(f" {sign} {abs(coefs[i]):.3f} {fns[i]}")
if not args.no_save:
save_model(model, args.out)
print(f"\nSaved model -> {args.out}")
if args.disagreements:
print("\n=== Films where the model disagrees with stored MPA ===")
diffs = disagreement_report(model, args.db)
if not diffs:
print(" None.")
else:
print(f" {len(diffs)} disagreements (most confident first):")
for d in diffs[:25]:
print(f" {d['title']:<40} "
f"actual={d['mpa_actual']:<6} "
f"predicted={d['predicted']:<7} "
f"conf={d['confidence']:.2f}")
if __name__ == "__main__":
main()