Download course_code/01_fine_tuning/train.py from Evan2027/disaster-tweet-classifier: direct link, hf CLI and curl.
- Browser
- Download file 9.32 kB
-
https://huggingface.co/spaces/Evan2027/disaster-tweet-classifier/resolve/main/course_code/01_fine_tuning/train.py
- Command line
-
hf download hf://spaces/Evan2027/disaster-tweet-classifier/course_code/01_fine_tuning/train.py
-
curl -L -o train.py https://huggingface.co/spaces/Evan2027/disaster-tweet-classifier/resolve/main/course_code/01_fine_tuning/train.py
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])} | |
| 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() | |