Text Classification
Scikit-learn
Portuguese
intent-classification
lead-scoring
tf-idf
logistic-regression
portuguese
Instructions to use ranzouu/leadforge-intent-classifier with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Scikit-learn
How to use ranzouu/leadforge-intent-classifier with Scikit-learn:
from huggingface_hub import hf_hub_download import joblib model = joblib.load( hf_hub_download("ranzouu/leadforge-intent-classifier", "sklearn_model.joblib") ) # only load pickle files from sources you trust # read more about it here https://skops.readthedocs.io/en/stable/persistence.html - Notebooks
- Google Colab
- Kaggle
Download experiment.py from ranzouu/leadforge-intent-classifier: direct link, hf CLI and curl.
- Browser
- Download file 12.1 kB
-
https://huggingface.co/ranzouu/leadforge-intent-classifier/resolve/main/experiment.py
- Command line
-
hf download hf://ranzouu/leadforge-intent-classifier/experiment.py
-
curl -L -o experiment.py https://huggingface.co/ranzouu/leadforge-intent-classifier/resolve/main/experiment.py
12.1 kB
| from __future__ import annotations | |
| import csv | |
| import json | |
| import statistics | |
| from dataclasses import dataclass, asdict | |
| from pathlib import Path | |
| from typing import Iterable | |
| import numpy as np | |
| from sklearn.feature_extraction.text import TfidfVectorizer | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.metrics import accuracy_score, precision_recall_fscore_support | |
| from sklearn.model_selection import StratifiedKFold, train_test_split | |
| from sklearn.pipeline import FeatureUnion | |
| DATA_PATH = Path(__file__).resolve().parents[2] / "data" / "sample_leads.json" | |
| MODEL_DIR = Path(__file__).resolve().parents[2] / "model" | |
| RUNS_DIR = Path(__file__).resolve().parents[2] / "runs" | |
| CV_FOLDS = 5 | |
| RANDOM_STATE = 42 | |
| C_GRID = [0.5, 1.0, 2.0, 4.0] | |
| def _make_features() -> FeatureUnion: | |
| """Uniao de features: word n-grams (sintaxe) + char_wb n-grams (morfologia | |
| do PT-BR — pega radical de 'contrat/automat/pesquis' sem depender de stemmer). | |
| """ | |
| return FeatureUnion( | |
| [ | |
| ("word", TfidfVectorizer(ngram_range=(1, 2), min_df=1, sublinear_tf=True)), | |
| ("char", TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4), min_df=1, sublinear_tf=True)), | |
| ] | |
| ) | |
| def _make_clf(C: float = 1.0) -> LogisticRegression: | |
| return LogisticRegression(max_iter=2000, class_weight="balanced", random_state=RANDOM_STATE, C=C) | |
| def _tune_c(texts: list[str], labels: list[str]) -> tuple[float, dict]: | |
| """Grid manual de C com CV interno — usado DENTRO do fold de treino | |
| (nested CV): o tuning nunca ve o fold de teste. | |
| """ | |
| best_c, best_score = 1.0, -1.0 | |
| scores: dict[str, float] = {} | |
| inner = StratifiedKFold(n_splits=3, shuffle=True, random_state=RANDOM_STATE) | |
| for c in C_GRID: | |
| fold_scores = [] | |
| for tr, va in inner.split(texts, labels): | |
| vec = _make_features() | |
| X_tr = vec.fit_transform([texts[i] for i in tr]) | |
| X_va = vec.transform([texts[i] for i in va]) | |
| clf = _make_clf(c) | |
| clf.fit(X_tr, [labels[i] for i in tr]) | |
| _, _, f1, _ = precision_recall_fscore_support( | |
| [labels[i] for i in va], clf.predict(X_va), average="macro", zero_division=0 | |
| ) | |
| fold_scores.append(float(f1)) | |
| mean = float(np.mean(fold_scores)) | |
| scores[str(c)] = round(mean, 4) | |
| if mean > best_score: | |
| best_c, best_score = c, mean | |
| return best_c, scores | |
| class Lead: | |
| id: int | |
| text: str | |
| label: str | |
| class RunRecord: | |
| id: int | |
| text: str | |
| expected: str | |
| predicted: str | |
| correct: bool = False | |
| def _load_leads() -> list[Lead]: | |
| with open(DATA_PATH, "r", encoding="utf-8") as f: | |
| return [Lead(**item) for item in json.load(f)] | |
| def _preprocess(text: str) -> str: | |
| return text.lower() | |
| def _make_pipeline(): | |
| return ( | |
| TfidfVectorizer(ngram_range=(1, 2), min_df=1), | |
| LogisticRegression(max_iter=1000, class_weight="balanced", random_state=RANDOM_STATE), | |
| ) | |
| def train_model(leads: list[Lead]): | |
| """Treina o artefato final em TODOS os dados (uso em producao/demo). | |
| O C vem de grid CV em dados completos; a avaliacao honesta NAO usa este | |
| modelo — ver `cross_validate` (tuning aninhado dentro de cada fold). | |
| """ | |
| texts = [_preprocess(l.text) for l in leads] | |
| labels = [l.label for l in leads] | |
| best_c, _ = _tune_c(texts, labels) | |
| vectorizer = _make_features() | |
| X = vectorizer.fit_transform(texts) | |
| clf = _make_clf(best_c) | |
| clf.fit(X, labels) | |
| clf.C_TUNED = best_c # type: ignore[attr-defined] | |
| return vectorizer, clf | |
| def evaluate(vectorizer, clf, leads: list[Lead]) -> tuple[list[RunRecord], dict]: | |
| """Avaliacao in-sample (smoke test). NAO usar como metrica publicada: | |
| aqui o modelo ve os mesmos exemplos no treino e na avaliacao. | |
| """ | |
| texts = [_preprocess(l.text) for l in leads] | |
| X = vectorizer.transform(texts) | |
| preds = clf.predict(X) | |
| records: list[RunRecord] = [] | |
| for lead, pred in zip(leads, preds): | |
| records.append( | |
| RunRecord(id=lead.id, text=lead.text, expected=lead.label, predicted=str(pred), correct=str(pred) == lead.label) | |
| ) | |
| acc = accuracy_score([r.expected for r in records], [r.predicted for r in records]) | |
| precision, recall, f1, _ = precision_recall_fscore_support( | |
| [r.expected for r in records], [r.predicted for r in records], average="weighted", zero_division=0 | |
| ) | |
| summary = { | |
| "accuracy": round(float(acc), 4), | |
| "precision": round(float(precision), 4), | |
| "recall": round(float(recall), 4), | |
| "f1": round(float(f1), 4), | |
| "samples": len(records), | |
| "mode": "in-sample", | |
| } | |
| return records, summary | |
| def cross_validate(leads: list[Lead], k: int = CV_FOLDS) -> tuple[list[RunRecord], dict, list[dict], list[dict]]: | |
| """Avaliacao honesta: StratifiedKFold com vectorizer treinado SOMENTE | |
| no fold de treino (sem vazamento). Retorna predicoes pooled out-of-fold, | |
| resumo com media + desvio por fold, linhas por fold e relatorio por classe. | |
| """ | |
| texts = [_preprocess(l.text) for l in leads] | |
| labels = [l.label for l in leads] | |
| classes = sorted(set(labels)) | |
| skf = StratifiedKFold(n_splits=k, shuffle=True, random_state=RANDOM_STATE) | |
| pooled: list[RunRecord] = [] | |
| fold_rows: list[dict] = [] | |
| fold_weighted: list[dict] = [] | |
| tuned_cs: list[float] = [] | |
| for fold, (train_idx, test_idx) in enumerate(skf.split(texts, labels), start=1): | |
| train_texts = [texts[i] for i in train_idx] | |
| train_labels = [labels[i] for i in train_idx] | |
| # tuning aninhado: C escolhido só com o fold de treino | |
| best_c, _ = _tune_c(train_texts, train_labels) | |
| tuned_cs.append(best_c) | |
| vectorizer = _make_features() | |
| X_train = vectorizer.fit_transform(train_texts) | |
| clf = _make_clf(best_c) | |
| clf.fit(X_train, train_labels) | |
| preds = clf.predict(vectorizer.transform([texts[i] for i in test_idx])) | |
| for idx, pred in zip(test_idx, preds): | |
| pooled.append( | |
| RunRecord(id=leads[idx].id, text=leads[idx].text, expected=labels[idx], predicted=str(pred)) | |
| ) | |
| y_true = [labels[i] for i in test_idx] | |
| precision, recall, f1, _ = precision_recall_fscore_support( | |
| y_true, preds, average="weighted", zero_division=0 | |
| ) | |
| fold_weighted.append( | |
| { | |
| "accuracy": float(accuracy_score(y_true, preds)), | |
| "precision": float(precision), | |
| "recall": float(recall), | |
| "f1": float(f1), | |
| } | |
| ) | |
| fold_rows.append( | |
| { | |
| "fold": fold, | |
| "train": len(train_idx), | |
| "test": len(test_idx), | |
| "accuracy": round(fold_weighted[-1]["accuracy"], 4), | |
| "precision": round(fold_weighted[-1]["precision"], 4), | |
| "recall": round(fold_weighted[-1]["recall"], 4), | |
| "f1": round(fold_weighted[-1]["f1"], 4), | |
| } | |
| ) | |
| y_true = [r.expected for r in pooled] | |
| y_pred = [r.predicted for r in pooled] | |
| pooled_precision, pooled_recall, pooled_f1, support = precision_recall_fscore_support( | |
| y_true, y_pred, labels=classes, zero_division=0 | |
| ) | |
| macro_precision, macro_recall, macro_f1, _ = precision_recall_fscore_support( | |
| y_true, y_pred, average="macro", zero_division=0 | |
| ) | |
| class_rows = [ | |
| { | |
| "class": cls, | |
| "precision": round(float(p), 4), | |
| "recall": round(float(r), 4), | |
| "f1": round(float(f), 4), | |
| "support": int(s), | |
| } | |
| for cls, p, r, f, s in zip(classes, pooled_precision, pooled_recall, pooled_f1, support) | |
| ] | |
| def _mean_std(key: str) -> tuple[float, float]: | |
| values = [row[key] for row in fold_weighted] | |
| return float(statistics.mean(values)), float(statistics.stdev(values) if len(values) > 1 else 0.0) | |
| mean_acc, std_acc = _mean_std("accuracy") | |
| mean_p, std_p = _mean_std("precision") | |
| mean_r, std_r = _mean_std("recall") | |
| mean_f1, std_f1 = _mean_std("f1") | |
| summary = { | |
| "mode": f"cv{k}", | |
| "accuracy": round(mean_acc, 4), | |
| "accuracy_std": round(std_acc, 4), | |
| "precision": round(mean_p, 4), | |
| "precision_std": round(std_p, 4), | |
| "recall": round(mean_r, 4), | |
| "recall_std": round(std_r, 4), | |
| "f1": round(mean_f1, 4), | |
| "f1_std": round(std_f1, 4), | |
| "macro_f1": round(float(macro_f1), 4), | |
| "folds": k, | |
| "samples": len(pooled), | |
| "c_per_fold": ";".join(str(c) for c in tuned_cs), | |
| "_fold_rows": fold_rows, | |
| "_class_rows": class_rows, | |
| } | |
| return pooled, summary, fold_rows, class_rows | |
| def save_runs(records: list[RunRecord], summary: dict, runs_dir: str = "runs") -> tuple[Path, Path]: | |
| runs = Path(runs_dir) | |
| runs.mkdir(parents=True, exist_ok=True) | |
| latest_path = runs / "latest.csv" | |
| summary_path = runs / "summary.csv" | |
| with open(latest_path, "w", newline="", encoding="utf-8") as f: | |
| writer = csv.DictWriter(f, fieldnames=list(RunRecord.__dataclass_fields__.keys())) | |
| writer.writeheader() | |
| for r in records: | |
| writer.writerow(asdict(r)) | |
| detail = {k: summary.pop(k) for k in list(summary) if k.startswith("_")} | |
| with open(summary_path, "w", newline="", encoding="utf-8") as f: | |
| writer = csv.DictWriter(f, fieldnames=list(summary.keys())) | |
| writer.writeheader() | |
| writer.writerow(summary) | |
| for key, name in (("_fold_rows", "cv_folds.csv"), ("_class_rows", "class_report.csv")): | |
| if key in detail: | |
| with open(runs / name, "w", newline="", encoding="utf-8") as f: | |
| writer = csv.DictWriter(f, fieldnames=list(detail[key][0].keys())) | |
| writer.writeheader() | |
| writer.writerows(detail[key]) | |
| return latest_path, summary_path | |
| def predict_text(vectorizer, clf, text: str) -> str: | |
| X = vectorizer.transform([_preprocess(text)]) | |
| return str(clf.predict(X)[0]) | |
| def _demo_predict(text: str) -> str: | |
| t = text.lower() | |
| if any(w in t for w in ["quero contratar", "quero automatizar", "preciso de um site", "quero um aplicativo", "quero contratar um personal"]): | |
| return "hot" | |
| if any(w in t for w in ["estou procurando", "quero melhorar", "precisa de um sistema", "precisa de ajuda", "começando"]): | |
| return "warm" | |
| return "cold" | |
| def run_experiment(demo: bool = False, k: int = CV_FOLDS) -> tuple[list[RunRecord], dict]: | |
| """Fluxo principal. | |
| demo=True: classificacao por regras (sem treino). | |
| demo=False: treina o artefato final em todos os dados E publica as metricas | |
| honestas de validacao cruzada estratificada (out-of-fold) em runs/. | |
| """ | |
| leads = _load_leads() | |
| MODEL_DIR.mkdir(parents=True, exist_ok=True) | |
| RUNS_DIR.mkdir(parents=True, exist_ok=True) | |
| if demo: | |
| records = [] | |
| for l in leads: | |
| pred = _demo_predict(l.text) | |
| records.append( | |
| RunRecord(id=l.id, text=l.text, expected=l.label, predicted=pred, correct=pred == l.label) | |
| ) | |
| acc = sum(1 for r in records if r.correct) / max(len(records), 1) | |
| precision, recall, f1, _ = precision_recall_fscore_support( | |
| [r.expected for r in records], [r.predicted for r in records], average="weighted", zero_division=0 | |
| ) | |
| summary = { | |
| "accuracy": round(float(acc), 4), | |
| "precision": round(float(precision), 4), | |
| "recall": round(float(recall), 4), | |
| "f1": round(float(f1), 4), | |
| "samples": len(records), | |
| "mode": "demo", | |
| } | |
| return records, summary | |
| import joblib | |
| vectorizer, clf = train_model(leads) | |
| joblib.dump(vectorizer, MODEL_DIR / "vectorizer.pkl") | |
| joblib.dump(clf, MODEL_DIR / "model.pkl") | |
| records, summary, _, _ = cross_validate(leads, k=k) | |
| summary["final_C"] = float(getattr(clf, "C_TUNED", clf.C)) | |
| return records, summary | |