| """ |
| TELEN: Temporal Evolving Legal Embedding Network β Training Script. |
| |
| Stages: |
| 1. Contrastive pretraining (5 epochs) β train projection head |
| 2. Meta-training (50 epochs) β train HyperNetwork + State Encoder |
| |
| Usage: |
| python train.py |
| """ |
| import sys, os, math, random |
| from pathlib import Path |
| from collections import defaultdict |
| import numpy as np |
| import pandas as pd |
| import torch, torch.nn as nn, torch.nn.functional as F |
| from torch.utils.data import DataLoader, Dataset |
| from tqdm import tqdm |
| from transformers import AutoTokenizer |
| from pyvi import ViTokenizer |
|
|
| sys.path.insert(0, ".") |
| from src.telern.config import TELENConfig, DATA_DIR |
| from src.telern.model import TELEN, create_model |
| from src.data import load_raw_data, extract_metadata, clean_data |
|
|
| SEED = 42 |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
|
|
| |
| |
| |
| def prepare_data(config): |
| df = load_raw_data(str(DATA_DIR / "train-00000-of-00001.parquet")) |
| df = extract_metadata(df); df = clean_data(df, min_text_len=10) |
| articles_by_law = defaultdict(list) |
| laws_by_year = defaultdict(list) |
| for _, row in df.iterrows(): |
| articles_by_law[row["law_id"]].append({ |
| "id": row["id"], "title": row["title"], "text": row["text"], |
| "law_type": row["law_type"], "year": row["year"], |
| }) |
| for law_id in articles_by_law: |
| laws_by_year[articles_by_law[law_id][0]["year"]].append(law_id) |
| all_years = sorted(laws_by_year.keys()) |
| train_years = [y for y in all_years if y <= config.meta.train_split_year] |
| val_years = [y for y in all_years if config.meta.train_split_year < y <= config.meta.val_split_year] |
| test_years = [y for y in all_years if y > config.meta.val_split_year] |
| return articles_by_law, laws_by_year, train_years, val_years, test_years, df |
|
|
| |
| |
| |
| class ContrastiveDataset(Dataset): |
| def __init__(self, df, tokenizer, max_len=480): |
| self.df = df.reset_index(drop=True) |
| self.tokenizer = tokenizer |
| self.max_len = max_len |
| self.law_groups = self.df.groupby("law_id") |
| self.law_ids = list(self.law_groups.groups.keys()) |
|
|
| def __len__(self): return len(self.df) |
|
|
| def __getitem__(self, idx): |
| row = self.df.iloc[idx]; law_id = row["law_id"] |
| wseg = lambda t: ViTokenizer.tokenize(t.replace("_", " ")) |
| anchor = wseg(f"{row['title']}: {row['text'][:400]}") |
| group_idx = self.law_groups.groups[law_id] |
| others = [i for i in group_idx if i != idx] |
| pos_row = self.df.iloc[random.choice(others)] if others else row |
| positive = wseg(f"{pos_row['title']}: {pos_row['text'][:400]}") |
| neg_law = random.choice([l for l in self.law_ids if l != law_id]) |
| neg_row = self.df.iloc[random.choice(list(self.law_groups.groups[neg_law]))] |
| negative = wseg(f"{neg_row['title']}: {neg_row['text'][:400]}") |
|
|
| def tok(t): return self.tokenizer(t, truncation=True, max_length=self.max_len, padding="max_length", return_tensors="pt") |
| return {f"{k}_{s}": tok(t)[k].squeeze(0) |
| for t, s in [(anchor,"a"),(positive,"p"),(negative,"n")] |
| for k in ["input_ids","attention_mask"]} |
|
|
| |
| |
| |
| def contrastive_pretrain(model, df, config, epochs=5, batch_size=24, lr=3e-5): |
| tokenizer = model.encoder.tokenizer |
| dataset = ContrastiveDataset(df, tokenizer, config.max_seq_length) |
| loader = DataLoader(dataset, batch_size=batch_size, shuffle=True) |
|
|
| trainable = list(model.base_projection.parameters()) + [model.attn_query] |
| opt = torch.optim.AdamW(trainable, lr=lr, weight_decay=0.01) |
| sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs * len(loader)) |
|
|
| print(f" Contrastive pretraining: {epochs} epochs, {len(loader)} batches") |
| model.train(); model.encoder.model.eval() |
|
|
| for epoch in range(epochs): |
| total = 0.0 |
| for batch in tqdm(loader, desc=f" Epoch {epoch+1}/{epochs}"): |
| a_ids=batch["input_ids_a"].to(device); a_mask=batch["attention_mask_a"].to(device) |
| p_ids=batch["input_ids_p"].to(device); p_mask=batch["attention_mask_p"].to(device) |
| n_ids=batch["input_ids_n"].to(device); n_mask=batch["attention_mask_n"].to(device) |
|
|
| with torch.no_grad(): |
| ah=model._pool(model.encoder.model(input_ids=a_ids,attention_mask=a_mask).last_hidden_state,a_mask) |
| ph=model._pool(model.encoder.model(input_ids=p_ids,attention_mask=p_mask).last_hidden_state,p_mask) |
| nh=model._pool(model.encoder.model(input_ids=n_ids,attention_mask=n_mask).last_hidden_state,n_mask) |
|
|
| ae=F.normalize(model.base_projection(ah),p=2,dim=1) |
| pe=F.normalize(model.base_projection(ph),p=2,dim=1) |
| ne=F.normalize(model.base_projection(nh),p=2,dim=1) |
|
|
| trip=F.relu(0.3-(ae*pe).sum(1)+(ae*ne).sum(1)).mean() |
| sim=ae@torch.cat([ae,pe,ne],dim=0).T/0.05 |
| infonce=F.cross_entropy(sim,torch.arange(len(a_ids),device=device)+len(a_ids)) |
| loss=trip+0.5*infonce |
|
|
| opt.zero_grad(); loss.backward() |
| torch.nn.utils.clip_grad_norm_(trainable,1.0); opt.step(); sched.step() |
| total+=loss.item() |
| print(f" Epoch {epoch+1} avg loss: {total/len(loader):.4f}") |
| print(" Contrastive pretraining complete!") |
| return model |
|
|
| |
| |
| |
| def build_episode(articles_by_law, laws_by_year, state_years, query_year, config): |
| mc = config.meta |
| q_laws = laws_by_year.get(query_year, []) |
| if len(q_laws) < 5: return None |
| sampled = random.sample(q_laws, min(mc.n_query // 4, len(q_laws))) |
| queries, positives, q_types = [], [], set() |
| for lid in sampled: |
| arts = articles_by_law[lid] |
| if len(arts) < 2: continue |
| qi, pi = random.sample(range(len(arts)), 2) |
| queries.append(arts[qi]); positives.append(arts[pi]) |
| q_types.add(arts[qi]["law_type"]) |
| if len(queries) < 4: return None |
|
|
| hard_neg, rand_neg = [], [] |
| for lid in q_laws: |
| if lid in sampled: continue |
| for a in articles_by_law[lid]: |
| if a["law_type"] in q_types: hard_neg.append(a) |
| else: rand_neg.append(a) |
|
|
| nh = min(mc.n_negatives // 2, len(hard_neg)) |
| nr = min(mc.n_negatives - nh, len(rand_neg)) |
| negatives = (random.sample(hard_neg, nh) if nh > 0 else []) + (random.sample(rand_neg, nr) if nr > 0 else []) |
| if len(negatives) < 4: return None |
| return {"queries": queries, "positives": positives, "negatives": negatives} |
|
|
| |
| |
| |
| def compute_loss(model, q_texts, p_texts, n_texts, state_vec, temp=0.05): |
| n_q, n_p = len(q_texts), len(p_texts) |
| if n_q == 0 or n_p == 0: |
| return torch.tensor(0.0, device=device, requires_grad=True) |
| all_t = q_texts + p_texts + n_texts |
| raw = model.encode_text(all_t) |
| adapted = model.adapt_embedding(raw, state_vec) |
| emb = adapted["mean"] |
| qe = emb[:n_q]; pe = emb[n_q:n_q+n_p]; ne = emb[n_q+n_p:] |
|
|
| if n_q == n_p: |
| sim = torch.cat([(qe*pe).sum(1).unsqueeze(1)/temp, qe@ne.T/temp], dim=1) |
| loss = F.cross_entropy(sim, torch.zeros(n_q, dtype=torch.long, device=device)) |
| else: |
| loss = F.cross_entropy(qe @ torch.cat([pe, ne], dim=0).T / temp, |
| torch.arange(n_q, device=device).clamp(max=len(pe)-1)) |
|
|
| if model.config.hypernetwork.output_variance: |
| lv = adapted.get("log_variance") |
| if lv is not None: loss = loss + (lv.exp() - lv - 1).mean() * model.config.meta.kl_weight |
| return loss |
|
|
| def validate(model, articles_by_law, laws_by_year, val_years, config): |
| model.eval(); losses = [] |
| with torch.no_grad(): |
| for _ in range(30): |
| qy = random.choice(val_years) |
| if qy not in laws_by_year: continue |
| sy = [y for y in sorted(laws_by_year.keys()) if y < qy] |
| if len(sy) < 3: sy = [y for y in sorted(laws_by_year.keys()) if y <= qy] |
| ep = build_episode(articles_by_law, laws_by_year, sy, qy, config) |
| if ep is None: continue |
| sv = model.get_state_vector() |
| losses.append(compute_loss(model, |
| [f"{q['title']}: {q['text'][:200]}" for q in ep["queries"]], |
| [f"{p['title']}: {p['text'][:200]}" for p in ep["positives"]], |
| [f"{n['title']}: {n['text'][:200]}" for n in ep["negatives"]], |
| sv, config.meta.temperature).item()) |
| return sum(losses)/max(len(losses),1) |
|
|
| def meta_train(model, articles_by_law, laws_by_year, train_years, val_years, config, epochs=50, patience=15): |
| trainable = (list(model.hypernetwork.parameters()) + list(model.state_encoder.parameters()) + |
| list(model.base_projection.parameters()) + [model.attn_query]) |
| opt = torch.optim.AdamW(trainable, lr=config.meta.meta_lr, weight_decay=1e-4) |
| sched = torch.optim.lr_scheduler.ReduceLROnPlateau(opt, mode='min', factor=0.5, patience=5, min_lr=1e-6) |
|
|
| os.makedirs(config.output_dir, exist_ok=True) |
| best_val, patience_ctr = float("inf"), 0 |
|
|
| for epoch in range(epochs): |
| model.train(); total_loss = 0.0 |
| steps = config.meta.meta_batch_size * 100 |
| progress = tqdm(range(steps), desc=f"Meta Epoch {epoch+1}/{epochs}") |
| for _ in progress: |
| if len(train_years) < 3: break |
| si = random.randint(2, len(train_years)-1) |
| sy, qy = train_years[:si], train_years[si] |
| if qy not in laws_by_year: continue |
| ep = build_episode(articles_by_law, laws_by_year, sy, qy, config) |
| if ep is None: continue |
| sv = model.get_state_vector() |
| loss = compute_loss(model, |
| [f"{q['title']}: {q['text'][:200]}" for q in ep["queries"]], |
| [f"{p['title']}: {p['text'][:200]}" for p in ep["positives"]], |
| [f"{n['title']}: {n['text'][:200]}" for n in ep["negatives"]], |
| sv, config.meta.temperature) |
| opt.zero_grad(); loss.backward() |
| torch.nn.utils.clip_grad_norm_(trainable, 1.0); opt.step() |
| total_loss += loss.item() |
| progress.set_postfix({"loss": f"{loss.item():.4f}"}) |
|
|
| avg = total_loss / max(steps, 1) |
| print(f" Epoch {epoch+1} avg loss: {avg:.4f}") |
|
|
| vl = validate(model, articles_by_law, laws_by_year, val_years, config) |
| print(f" Val loss: {vl:.4f}") |
| sched.step(vl) |
|
|
| if vl < best_val: |
| best_val, patience_ctr = vl, 0 |
| torch.save({ |
| "hypernetwork": model.hypernetwork.state_dict(), |
| "state_encoder": model.state_encoder.state_dict(), |
| "base_projection": model.base_projection.state_dict(), |
| "attn_query": model.attn_query, |
| "epoch": epoch, "val_loss": vl, |
| }, Path(config.output_dir) / "telen_best.pt") |
| print(f" Saved (val_loss={vl:.4f})") |
| else: |
| patience_ctr += 1 |
| if patience_ctr >= patience: |
| print(f" Early stopping at epoch {epoch+1}"); break |
| print("Meta-training complete!") |
| return model |
|
|
| |
| |
| |
| def main(): |
| config = TELENConfig() |
| random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED) |
| print(f"Device: {device}") |
|
|
| |
| print("\nLoading data...") |
| articles_by_law, laws_by_year, train_years, val_years, test_years, df = prepare_data(config) |
| print(f" Train: {train_years[0]}-{train_years[-1]} ({len(train_years)}y)") |
| print(f" Val: {val_years[0]}-{val_years[-1]} ({len(val_years)}y)") |
| print(f" Test: {len(test_years)}y") |
|
|
| |
| print("\nCreating TELEN...") |
| model = create_model(config).to(device) |
| print(f" HyperNetwork: {sum(p.numel() for p in model.hypernetwork.parameters()):,} params") |
|
|
| |
| print("\nBuilding concept graph...") |
| train_df = df[df["year"].isin(train_years)] |
| model.build_graph(train_df) |
| print(f" Graph: {model.concept_graph.num_nodes} nodes") |
|
|
| |
| print("\n" + "=" * 60) |
| print("Stage 1: Contrastive Pretraining") |
| print("=" * 60) |
| model = contrastive_pretrain(model, train_df, config, epochs=5, batch_size=24, lr=3e-5) |
|
|
| |
| print("\n" + "=" * 60) |
| print("Stage 2: Meta-Training") |
| print("=" * 60) |
| model = meta_train(model, articles_by_law, laws_by_year, train_years, val_years, config, epochs=50, patience=15) |
|
|
| print(f"\nDone! Model saved to: {config.output_dir}/telen_best.pt") |
|
|
| if __name__ == "__main__": |
| main() |
|
|