Evan2027's picture
Deploy tested browser classifier and course training code
8e13d4f verified
Raw History Blame Contribute Delete
9.32 kB
"""Local BERT fine tuning, validation checkpoint selection and held-out evaluation."""
import argparse
import copy
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np
import pandas as pd
import torch
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.metrics import accuracy_score, precision_recall_fscore_support, confusion_matrix, roc_auc_score, f1_score
from torch.utils.data import DataLoader, Dataset
from transformers import AutoTokenizer, AutoModelForSequenceClassification, get_linear_schedule_with_warmup
from common.io import ROOT, dump, seed_all, environment, sha256
def output_directories(args):
"""Keep the original seed-42 defaults; isolate every optional repeat run."""
original_results = (ROOT / "results/fine_tuning").resolve()
original_model = (ROOT / "models/disaster-bert").resolve()
custom = args.result_dir is not None or args.model_output_dir is not None
base = Path(args.base_model_dir)
base = (base if base.is_absolute() else ROOT / base).resolve()
original_base = base == (ROOT / "models/bert-mini").resolve()
original_id = args.model_id == "google/bert_uncased_L-4_H-256_A-4"
if not custom and args.seed == 42 and original_base and original_id:
return original_results, original_model
if args.result_dir is None or args.model_output_dir is None:
raise ValueError("Repeat/custom runs require both --result-dir and --model-output-dir")
def resolve(value):
path = Path(value)
return (path if path.is_absolute() else ROOT / path).resolve()
results, model = resolve(args.result_dir), resolve(args.model_output_dir)
for target in (results, model):
for protected in (original_results, original_model):
if target == protected or protected in target.parents or target in protected.parents:
raise ValueError(f"Custom output overlaps the protected original artifacts: {target}")
if target.exists() and (not target.is_dir() or any(target.iterdir())):
raise ValueError(f"Custom output must be an empty/new directory: {target}")
if results == model or results in model.parents or model in results.parents:
raise ValueError("Result and model output directories must be separate")
return results, model
def metrics(y, pred, prob=None):
p, r, f, _ = precision_recall_fscore_support(y, pred, average="binary", zero_division=0)
m = dict(accuracy=float(accuracy_score(y, pred)), precision=float(p), recall=float(r), f1=float(f),
macro_f1=float(f1_score(y, pred, average="macro")), confusion_matrix=confusion_matrix(y, pred, labels=[0,1]).tolist())
if prob is not None:
m["roc_auc"] = float(roc_auc_score(y, prob))
return m
def bootstrap_ci(y, pred, seed=42):
rng = np.random.default_rng(seed)
scores = []
y, pred = np.asarray(y), np.asarray(pred)
for _ in range(1000):
ix = rng.integers(0, len(y), len(y))
scores.append(f1_score(y[ix], pred[ix], zero_division=0))
return np.quantile(scores, [0.025, 0.975]).tolist()
class Tweets(Dataset):
def __init__(self, df, tokenizer, max_length):
self.enc = tokenizer(df.text.tolist(), truncation=True, max_length=max_length, padding="max_length")
self.labels = df.target.tolist()
def __len__(self):
return len(self.labels)
def __getitem__(self, i):
return {**{k: torch.tensor(v[i]) for k,v in self.enc.items()}, "labels": torch.tensor(self.labels[i])}
@torch.inference_mode()
def predict(model, loader, device):
model.eval()
probs = []
for batch in loader:
batch = {k:v.to(device) for k,v in batch.items()}
probs.extend(model(**batch).logits.softmax(-1)[:,1].cpu().tolist())
return np.asarray(probs)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--epochs", type=int, default=5)
ap.add_argument("--batch-size", type=int, default=32)
ap.add_argument("--lr", type=float, default=3e-5)
ap.add_argument("--max-length", type=int, default=96)
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--result-dir", help="Optional fresh result directory; relative paths use the assignment root")
ap.add_argument("--model-output-dir", help="Optional fresh checkpoint directory; relative paths use the assignment root")
ap.add_argument("--base-model-dir", default="models/bert-mini", help="Local base model/tokenizer directory; relative paths use the assignment root")
ap.add_argument("--model-id", default="google/bert_uncased_L-4_H-256_A-4", help="Base model identity recorded in the results")
args = ap.parse_args()
try:
out, model_out = output_directories(args)
except ValueError as error:
ap.error(str(error))
seed_all(args.seed)
device = "cuda" if torch.cuda.is_available() else "cpu"
out.mkdir(parents=True, exist_ok=True)
split_hashes = {name: sha256(ROOT / f"data/splits/{name}.csv") for name in ["train", "validation", "test"]}
parts = {name: pd.read_csv(ROOT / f"data/splits/{name}.csv", keep_default_na=False) for name in ["train","validation","test"]}
train, val, test = (parts[n] for n in ["train","validation","test"])
base_model = Path(args.base_model_dir)
base_model = base_model if base_model.is_absolute() else ROOT / base_model
tok = AutoTokenizer.from_pretrained(base_model, local_files_only=True)
loaders = {n:DataLoader(Tweets(df,tok,args.max_length), batch_size=args.batch_size, shuffle=n=="train", num_workers=0) for n,df in parts.items()}
model = AutoModelForSequenceClassification.from_pretrained(base_model, num_labels=2,
id2label={0:"Not disaster",1:"Disaster"}, label2id={"Not disaster":0,"Disaster":1}, local_files_only=True).to(device)
optimizer = torch.optim.AdamW(model.parameters(),lr=args.lr,weight_decay=0.01)
steps = args.epochs * len(loaders["train"])
scheduler = get_linear_schedule_with_warmup(optimizer, int(0.1*steps), steps)
history, best_score, best_state, best_epoch = [], -1, None, None
start = time.perf_counter()
for epoch in range(1,args.epochs+1):
model.train()
total_loss = 0
for batch in loaders["train"]:
batch = {k:v.to(device) for k,v in batch.items()}
optimizer.zero_grad(set_to_none=True)
loss = model(**batch).loss
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(),1.0)
optimizer.step(); scheduler.step()
total_loss += loss.item()*len(batch["labels"])
prob = predict(model,loaders["validation"],device)
result = dict(epoch=epoch, train_loss=total_loss/len(train), validation=metrics(val.target,prob>=0.5,prob))
history.append(result)
dump(out / "history.json",history)
print(json.dumps(result),flush=True)
if result["validation"]["f1"] > best_score:
best_score=result["validation"]["f1"]; best_epoch=epoch
best_state={k:v.detach().cpu().clone() for k,v in model.state_dict().items()}
model.load_state_dict(best_state)
model.save_pretrained(model_out)
tok.save_pretrained(model_out)
prob=predict(model,loaders["test"],device)
pred=(prob>=0.5).astype(int)
test_results=metrics(test.target,pred,prob)
test_results["bootstrap_f1_ci95"]=bootstrap_ci(test.target,pred)
baselines={}
majority=np.full(len(test),int(train.target.mode()[0]))
baselines["majority"]=metrics(test.target,majority)
pipeline=make_pipeline(TfidfVectorizer(lowercase=True,ngram_range=(1,2),min_df=2,sublinear_tf=True),
LogisticRegression(C=2.0,max_iter=1000,random_state=args.seed))
pipeline.fit(train.text,train.target)
base_prob=pipeline.predict_proba(test.text)[:,1]
base_pred=(base_prob>=0.5).astype(int)
baselines["tfidf_logistic"]=metrics(test.target,base_pred,base_prob)
result=dict(config=vars(args),device=device,environment=environment(),model=args.model_id,
data_split_sha256=split_hashes, model_output_dir=str(model_out), base_model_dir=str(base_model.resolve()),
trainable_parameters=sum(p.numel() for p in model.parameters()),best_epoch=best_epoch,
best_validation_f1=best_score,test=test_results,baselines=baselines,seconds=time.perf_counter()-start,
selection="best validation F1; fixed threshold 0.5; test evaluated after selection")
dump(out / "metrics.json",result)
pd.DataFrame(dict(id=test.id,text=test.text,gold=test.target,prediction=pred,prob_disaster=prob,
tfidf_prediction=base_pred,tfidf_prob=base_prob)).to_csv(out / "test_predictions.csv",index=False,encoding="utf-8-sig")
errors=test.assign(prediction=pred,prob_disaster=prob)
errors=errors[errors.target!=errors.prediction]
errors["confidence"]=np.maximum(errors.prob_disaster,1-errors.prob_disaster)
errors.sort_values("confidence",ascending=False).to_csv(out / "error_cases.csv",index=False,encoding="utf-8-sig")
print(json.dumps(result,ensure_ascii=False,indent=2),flush=True)
if __name__ == "__main__":
main()