# %% [markdown] # step 1: analyse the distribution of regular-granted, regular-not-granted, anticipatory-granted and anticipatory-not-granted (Done)
# step 2: create a good and balanced dataset having 25% cases from each catagory (Done)
# step 3.1: create a new encoder model with InLegalBert mixed with ChunkBERT architecture. (Not promising enough)
# step 3.2: create a new encoder model with InLegalBert mixed with HBERT architecture. (Idea Ditched)
# step 3.3: Try out BigBird model.
# step 3.3: expore other ideas from the paper "Effectively Leveraging BERT for Legal Document Classification" and other papers (Big Bird finalized)
# step 4.1: Formulate a full classification model around these encoder models. (Done)
# step 4.2: Find a way to properly process statutes. (Done)
# step 4.3: Train the model.
# step 5: Perform abelation study to check if these encoders actually actually help
# %% import pickle from sklearn.preprocessing import StandardScaler from sklearn.metrics import accuracy_score, precision_recall_fscore_support, confusion_matrix import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from torch.optim import AdamW from torch.amp import autocast, GradScaler from transformers import BigBirdTokenizer, BigBirdModel from transformers import get_linear_schedule_with_warmup import numpy as np import pandas as pd import os, json torch.backends.cudnn.benchmark = False # %% with open('Data/final_train_df.pkl', 'rb') as f: train_df = pickle.load(f) train_df.rename(columns={'xgb_oof_pred': 'xgb_pred'}, inplace=True) with open('Data/final_val_df.pkl', 'rb') as f: val_df = pickle.load(f) with open('Data/final_test_df.pkl', 'rb') as f: test_df = pickle.load(f) # %% [markdown] # ## Preprocessing Data # %% # train_df.info() # %% # Find the median of days_in_custody from train_df median_days = train_df['days_in_custody'].median() # Fill NaN values in all three dataframes with the median train_df['days_in_custody'].fillna(median_days, inplace=True) val_df['days_in_custody'].fillna(median_days, inplace=True) test_df['days_in_custody'].fillna(median_days, inplace=True) # %% # Find medians for age columns in train_df median_min_age = train_df['min_age'].median() median_max_age = train_df['max_age'].median() median_median_age = train_df['median_age'].median() # Fill NaN values in all three dataframes for df in [train_df, val_df, test_df]: df['min_age'].fillna(median_min_age, inplace=True) df['max_age'].fillna(median_max_age, inplace=True) df['median_age'].fillna(median_median_age, inplace=True) # %% train_df.columns # %% scaler = StandardScaler() columns_to_standardize = ['days_in_custody', 'min_age', 'max_age', 'median_age', 'shap_sum_pos', 'shap_sum_neg', 'shap_max_pos', 'shap_min_neg', 'shap_pos_count', 'shap_neg_count', 'shap_l1_total', 'shap_top3_abs_sum'] train_df[columns_to_standardize] = scaler.fit_transform(train_df[columns_to_standardize]) val_df[columns_to_standardize] = scaler.transform(val_df[columns_to_standardize]) test_df[columns_to_standardize] = scaler.transform(test_df[columns_to_standardize]) # %% print("Train (bail_type, outcome):") print(train_df.groupby(["bail_type", "outcome"]).size()) print("Val (bail_type, outcome):") print(val_df.groupby(["bail_type", "outcome"]).size()) print("Test (bail_type, outcome):") print(test_df.groupby(["bail_type", "outcome"]).size()) # %% [markdown] # ## Training # %% SCALAR_COLS = [ "bail_type", "days_in_custody_available", "days_in_custody", "age_available", "min_age", "max_age", "median_age", "shap_sum_pos", "shap_sum_neg", "shap_max_pos", "shap_min_neg", "shap_pos_count", "shap_neg_count", "shap_l1_total", "shap_top3_abs_sum", "xgb_pred", ] # %% class BailDataset(Dataset): def __init__(self, df): self.texts = df["details"].astype(str).tolist() self.y = df["outcome"].astype(np.float32).to_numpy() self.scalars = df[SCALAR_COLS].astype(np.float32).to_numpy() self.bail_type_raw = df["bail_type"].astype(np.int64).to_numpy() def __len__(self): return len(self.y) def __getitem__(self, idx): return { "text": self.texts[idx], "scalars": self.scalars[idx], # shape (16,) "label": self.y[idx], # scalar "bail_type_raw": int(self.bail_type_raw[idx]) } def make_collate_fn(tokenizer, max_length=2048): """ Returns a collate_fn that: - tokenizes a batch of texts with padding - stacks scalar features - returns tensors ready for the model """ def collate(batch): texts = [b["text"] for b in batch] scalars = torch.from_numpy(np.stack([b["scalars"] for b in batch])).float() # (B,16) labels = torch.from_numpy(np.array([b["label"] for b in batch], dtype=np.float32)) # (B,) bail_type_raw = torch.from_numpy(np.array([b["bail_type_raw"] for b in batch], dtype=np.int64)) enc = tokenizer( texts, padding=True, truncation=True, max_length=max_length, return_tensors="pt" ) # enc has: input_ids (B,T), attention_mask (B,T) return { "input_ids": enc["input_ids"], "attention_mask": enc["attention_mask"], "scalars": scalars, "labels": labels, "bail_type_raw": bail_type_raw, } return collate # %% class BigBirdScalarClassifier(nn.Module): def __init__(self, bigbird_name="google/bigbird-roberta-large", scalar_dim=16): super().__init__() self.bigbird = BigBirdModel.from_pretrained(bigbird_name) hidden = self.bigbird.config.hidden_size # 768 # Task-attention vector (single-head attention pooling) self.att_vector = nn.Parameter(torch.randn(hidden)) # learned # Text branch head self.drop_text1 = nn.Dropout(0.1) ## <----------- Turn this on for full model training and remove the identity below self.proj = nn.Linear(hidden, 512) self.ln = nn.LayerNorm(512) self.drop_text2 = nn.Dropout(0.2) ## <----------- Turn this on for full model training and remove the identity below # Final classifier on concatenated [text512 ; scalar16] = 528 self.classifier = nn.Linear(512 + scalar_dim, 1) def attention_pool(self, H, attention_mask): """ H: (B,T,768) token embeddings attention_mask: (B,T) 1 for real token, 0 for pad returns pooled: (B,768) """ # scores: (B,T) scores = torch.matmul(H, self.att_vector) # mask padding BEFORE softmax scores = scores.masked_fill(attention_mask == 0, -1e4) # alpha: (B,T) alpha = torch.softmax(scores, dim=1) # weighted sum: (B,768) pooled = torch.sum(H * alpha.unsqueeze(-1), dim=1) return pooled def forward(self, input_ids, attention_mask, scalars): # BigBird outputs token embeddings: (B,T,768) out = self.bigbird(input_ids=input_ids, attention_mask=attention_mask, return_dict=True) H = out.last_hidden_state # attention pooling -> (B,768) pooled = self.attention_pool(H, attention_mask) # text branch -> (B,512) x_text = self.drop_text1(pooled) x_text = self.proj(x_text) x_text = self.ln(x_text) x_text = self.drop_text2(x_text) # concatenate scalars -> (B,528) x = torch.cat([x_text, scalars], dim=1) # logits -> (B,1) -> squeeze -> (B,) logits = self.classifier(x).squeeze(1) return logits # %% def build_optimizer(model): # Separate parameter groups bigbird_params = list(model.bigbird.parameters()) att_params = [model.att_vector] head_params = list(model.proj.parameters()) + list(model.ln.parameters()) + list(model.classifier.parameters()) optimizer = AdamW([ {"params": bigbird_params, "lr": 5e-6}, {"params": att_params, "lr": 1e-3}, {"params": head_params, "lr": 1e-3}, ], weight_decay=0.01) return optimizer # %% def set_bigbird_trainable(model, trainable: bool): for p in model.bigbird.parameters(): p.requires_grad = trainable # %% def train_one_epoch(model, loader, optimizer, scheduler, criterion, device, scaler=None, max_grad_norm=1.0): model.train() total_loss = 0.0 for batch in loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) scalars = batch["scalars"].to(device) labels = batch["labels"].to(device) optimizer.zero_grad(set_to_none=True) if scaler is not None: with autocast("cuda"): logits = model(input_ids, attention_mask, scalars) loss = criterion(logits, labels) scaler.scale(loss).backward() # ---- GRADIENT CLIPPING (AMP SAFE) ---- scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm) scaler.step(optimizer) scaler.update() else: logits = model(input_ids, attention_mask, scalars) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm) optimizer.step() # ---- SCHEDULER STEP ---- if scheduler is not None: scheduler.step() total_loss += loss.item() * labels.size(0) return total_loss / len(loader.dataset) @torch.no_grad() def eval_one_epoch(model, loader, criterion, device): model.eval() total_loss = 0.0 for batch in loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) scalars = batch["scalars"].to(device) labels = batch["labels"].to(device) logits = model(input_ids, attention_mask, scalars) loss = criterion(logits, labels) total_loss += loss.item() * labels.size(0) return total_loss / len(loader.dataset) # %% @torch.no_grad() def predict_on_loader(model, loader, device): model.eval() all_logits = [] all_labels = [] all_bail_types = [] for batch in loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) scalars = batch["scalars"].to(device) labels = batch["labels"].to(device) bail_type_raw = batch["bail_type_raw"].to(device) logits = model(input_ids, attention_mask, scalars) # (B,) all_logits.append(logits.detach().cpu()) all_labels.append(labels.detach().cpu()) all_bail_types.append(bail_type_raw.detach().cpu()) logits = torch.cat(all_logits).numpy() labels = torch.cat(all_labels).numpy().astype(np.int64) bail_types = torch.cat(all_bail_types).numpy().astype(np.int64) return logits, labels, bail_types def compute_binary_metrics(labels, probs, threshold=0.5): preds = (probs >= threshold).astype(np.int64) acc = accuracy_score(labels, preds) # binary average gives scalar precision/recall/f1 prec, rec, f1, _ = precision_recall_fscore_support(labels, preds, average="binary", zero_division=0) cm = confusion_matrix(labels, preds, labels=[0, 1]) # [[TN, FP],[FN, TP]] return { "threshold": float(threshold), "accuracy": float(acc), "precision": float(prec), "recall": float(rec), "f1": float(f1), "confusion_matrix": { "tn": int(cm[0, 0]), "fp": int(cm[0, 1]), "fn": int(cm[1, 0]), "tp": int(cm[1, 1]), } }, preds, cm def logit_stats(logits, labels): """ Returns avg & median logits for labels==0 and labels==1. Handles empty slices safely. """ out = {} for y in [0, 1]: vals = logits[labels == y] if vals.size == 0: out[str(y)] = {"mean": None, "median": None, "count": 0} else: out[str(y)] = { "mean": float(np.mean(vals)), "median": float(np.median(vals)), "count": int(vals.size) } return out def evaluate_and_save( model, val_loader, device, out_dir="run_outputs", threshold=0.5, prefix="val" ): os.makedirs(out_dir, exist_ok=True) logits, labels, bail_types = predict_on_loader(model, val_loader, device) probs = 1.0 / (1.0 + np.exp(-logits)) # sigmoid # Overall metrics overall_metrics, preds, cm_overall = compute_binary_metrics(labels, probs, threshold=threshold) # Per bail_type metrics & confusion matrices per_type = {} cms = {} for bt in [1, 2]: idx = (bail_types == bt) if idx.sum() == 0: per_type[str(bt)] = None continue m, _, cm = compute_binary_metrics(labels[idx], probs[idx], threshold=threshold) per_type[str(bt)] = m cms[bt] = cm # Logit stats overall and per bail_type logit_summary = { "overall": logit_stats(logits, labels), "bail_type_1": logit_stats(logits[bail_types == 1], labels[bail_types == 1]) if (bail_types == 1).any() else None, "bail_type_2": logit_stats(logits[bail_types == 2], labels[bail_types == 2]) if (bail_types == 2).any() else None, } # Save a per-example CSV for later analysis df_pred = pd.DataFrame({ "logit": logits, "prob": probs, "pred": preds, "label": labels, "bail_type": bail_types }) df_pred_path = os.path.join(out_dir, f"{prefix}_predictions.csv") df_pred.to_csv(df_pred_path, index=False) # Save confusion matrices as CSVs def save_cm(cm, path): # cm is [[TN, FP], [FN, TP]] pd.DataFrame(cm, index=["true_0", "true_1"], columns=["pred_0", "pred_1"]).to_csv(path) save_cm(cm_overall, os.path.join(out_dir, f"{prefix}_confusion_overall.csv")) if 1 in cms: save_cm(cms[1], os.path.join(out_dir, f"{prefix}_confusion_bail_type_1.csv")) if 2 in cms: save_cm(cms[2], os.path.join(out_dir, f"{prefix}_confusion_bail_type_2.csv")) # Save summary JSON summary = { "split": prefix, "overall_metrics": overall_metrics, "per_bail_type_metrics": per_type, "logit_stats": logit_summary } summary_path = os.path.join(out_dir, f"{prefix}_metrics_summary.json") with open(summary_path, "w", encoding="utf-8") as f: json.dump(summary, f, indent=2) # Print the requested logit stats print("\n=== Logit stats (mean/median) by true outcome ===") print("Overall:", summary["logit_stats"]["overall"]) print("Bail type 1:", summary["logit_stats"]["bail_type_1"]) print("Bail type 2:", summary["logit_stats"]["bail_type_2"]) print("\n=== Overall metrics ===") print(summary["overall_metrics"]) print("\nSaved:") print(" -", df_pred_path) print(" -", summary_path) return summary # %% def save_model(model, out_dir="run_outputs", name="bigbird_scalar_model.pt"): os.makedirs(out_dir, exist_ok=True) path = os.path.join(out_dir, name) payload = { "model_state_dict": model.state_dict(), "bigbird_name": "google/bigbird-roberta-large", "scalar_cols": SCALAR_COLS, "architecture": { "pooling": "task_attention_single_head", "proj_dim": 512, "dropout_after_pool": 0.1, "dropout_after_ln": 0.2, } } torch.save(payload, path) print("Saved model to:", path) return path # %% def load_model(path, device): ckpt = torch.load(path, map_location=device) model = BigBirdScalarClassifier( bigbird_name=ckpt["bigbird_name"], scalar_dim=len(ckpt["scalar_cols"]) ).to(device) model.load_state_dict(ckpt["model_state_dict"]) model.eval() return model, ckpt # %% device = torch.device("cuda" if torch.cuda.is_available() else "cpu") tokenizer = BigBirdTokenizer.from_pretrained("google/bigbird-roberta-large") train_ds = BailDataset(train_df) val_ds = BailDataset(val_df) collate_fn = make_collate_fn(tokenizer, max_length=1200) # adjust train_loader = DataLoader(train_ds, batch_size=4, shuffle=True, collate_fn=collate_fn, num_workers=2) val_loader = DataLoader(val_ds, batch_size=4, shuffle=False, collate_fn=collate_fn, num_workers=2) model = BigBirdScalarClassifier().to(device) model.bigbird.gradient_checkpointing_enable() optimizer = build_optimizer(model) criterion = nn.BCEWithLogitsLoss() # Mixed precision helps a lot on GPU scaler = GradScaler("cuda") if device.type == "cuda" else None # %% # ========================= # PHASE 1 — Train head only # ========================= print("\n====================") print("PHASE 1: Training head only") print("====================\n") set_bigbird_trainable(model, False) model.drop_text1.p = 0.0 model.drop_text2.p = 0.0 optimizer = build_optimizer(model) # uses only head anyway scheduler = None # ❗ no scheduler in phase 1 best_val_loss = float("inf") best_model_path = "run_outputs/best_model_phase1.pt" patience = 5 bad_epochs = 0 max_phase1_epochs = 20 for epoch in range(max_phase1_epochs): train_loss = train_one_epoch( model, train_loader, optimizer, scheduler=None, # ❗ no scheduler criterion=criterion, device=device, scaler=scaler ) val_loss = eval_one_epoch(model, val_loader, criterion, device) print(f"[P1] Epoch {epoch} | train_loss={train_loss:.4f} | val_loss={val_loss:.4f}") if val_loss < best_val_loss - 1e-4: best_val_loss = val_loss bad_epochs = 0 torch.save({ "model_state_dict": model.state_dict(), "epoch": epoch, "val_loss": val_loss }, best_model_path) print(" ✅ New best Phase-1 model saved") else: bad_epochs += 1 print(f" ⚠️ No improvement for {bad_epochs} epochs") if bad_epochs >= patience: print("🛑 Phase 1 early stopping") break # %% # ------------- LOAD BEST MODEL FROM PHASE 1 CHECKPOINT ------------- ckpt = torch.load(best_model_path, map_location=device) model.load_state_dict(ckpt["model_state_dict"]) print("Loaded best model from epoch:", ckpt["epoch"]) # %% import gc gc.collect() torch.cuda.empty_cache() # %% # ========================= # PHASE 2 — Full fine-tuning # ========================= print("\n====================") print("PHASE 2: Full fine-tuning") print("====================\n") set_bigbird_trainable(model, True) model.drop_text1.p = 0.1 model.drop_text2.p = 0.2 # 🔁 IMPORTANT: Rebuild optimizer optimizer = build_optimizer(model) # 🔁 IMPORTANT: New scheduler starts NOW num_epochs_phase2 = 30 total_steps = num_epochs_phase2 * len(train_loader) warmup_steps = int(0.1 * total_steps) scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=warmup_steps, num_training_steps=total_steps ) best_val_loss = float("inf") best_model_path = "run_outputs/best_model_full.pt" patience = 8 # more patience after unfreezing bad_epochs = 0 for epoch in range(num_epochs_phase2): train_loss = train_one_epoch( model, train_loader, optimizer, scheduler, criterion, device, scaler ) val_loss = eval_one_epoch(model, val_loader, criterion, device) print(f"[P2] Epoch {epoch} | LR={scheduler.get_last_lr()[0]:.6f} | train_loss={train_loss:.4f} | val_loss={val_loss:.4f}") if val_loss < best_val_loss - 1e-4: best_val_loss = val_loss bad_epochs = 0 torch.save({ "model_state_dict": model.state_dict(), "epoch": epoch, "val_loss": val_loss }, best_model_path) print(" ✅ New best FULL model saved") else: bad_epochs += 1 print(f" ⚠️ No improvement for {bad_epochs} epochs") if bad_epochs >= patience: print("🛑 Phase 2 early stopping") break # %% # best_model_path = "run_outputs/best_model_phase1.pt" ckpt = torch.load(best_model_path, map_location=device) model.load_state_dict(ckpt["model_state_dict"]) print("Loaded best FULL model from epoch:", ckpt["epoch"]) # %% # model_path = save_model(model, out_dir="run_outputs") val_summary = evaluate_and_save( model=model, val_loader=val_loader, device=device, out_dir="run_outputs", threshold=0.5, prefix="val" ) # %% import matplotlib.pyplot as plt import numpy as np import pandas as pd val_pred_df = pd.read_csv("run_outputs/val_predictions.csv") # Histogram for logits logit_bins = np.arange(val_pred_df["logit"].min(), val_pred_df["logit"].max() + 0.05, 0.05) plt.figure(figsize=(8, 4)) plt.hist( val_pred_df.loc[val_pred_df["label"] == 0, "logit"], bins=logit_bins, alpha=0.6, color="steelblue", label="label 0", ) plt.hist( val_pred_df.loc[val_pred_df["label"] == 1, "logit"], bins=logit_bins, alpha=0.6, color="indianred", label="label 1", ) plt.title("Logit Distribution (bin=0.05)") plt.xlabel("logit") plt.ylabel("frequency") plt.legend() plt.tight_layout() plt.show() # Histogram for probabilities prob_bins = np.arange(0, 1.0001, 0.05) plt.figure(figsize=(8, 4)) plt.hist( val_pred_df.loc[val_pred_df["label"] == 0, "prob"], bins=prob_bins, alpha=0.6, color="steelblue", label="label 0", ) plt.hist( val_pred_df.loc[val_pred_df["label"] == 1, "prob"], bins=prob_bins, alpha=0.6, color="indianred", label="label 1", ) plt.title("Probability Distribution (bin=0.05)") plt.xlabel("probability") plt.ylabel("frequency") plt.legend() plt.tight_layout() plt.show() # %%