ranzouu's picture
Upload folder using huggingface_hub
8772612 verified
Raw History Blame Contribute Delete
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
@dataclass
class Lead:
id: int
text: str
label: str
@dataclass
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