Dharun72 commited on
Commit
3235126
·
verified ·
1 Parent(s): 71905e9

Q3 v1 standalone training script for Vast

Browse files
Files changed (1) hide show
  1. q3_train_v1_vast.py +261 -0
q3_train_v1_vast.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Q3 v1 — Contrastive fine-tune of multilingual-e5-large on pre-mined hard negatives.
2
+
3
+ Run on Vast.ai (RTX PRO 4500 / A100 / 4090). Memory-tight defaults from prior
4
+ notebook OOM debugging: AMP, BATCH_SIZE=16, gradient checkpointing-friendly.
5
+
6
+ Inputs:
7
+ - HF: Dharun72/swiss-legal-checkpoints/q3_train_v1.parquet (pre-mined pairs)
8
+ - Kaggle competition data: laws_de.csv, train.csv (val split), test.csv (encoding)
9
+
10
+ Outputs (pushed to HF Dharun72/swiss-legal-checkpoints):
11
+ - laws_emb_v2.npy (175933 × 1024 float32, L2-normalized)
12
+ - queries_v2.npz (test, train, val splits encoded)
13
+ - q3_v1_eval.json (recall@40 + novel-cit recall metrics)
14
+
15
+ Usage on Vast:
16
+ pip install sentence-transformers==2.7.0 transformers==4.45.2 huggingface-hub kaggle pandas pyarrow
17
+ export HF_TOKEN=...
18
+ python _q3_train_v1_vast.py
19
+ """
20
+ import os, sys, json, re, random, time, gc
21
+ from pathlib import Path
22
+ from collections import defaultdict
23
+ import numpy as np
24
+ import pandas as pd
25
+ import torch
26
+ from torch.utils.data import DataLoader
27
+
28
+ os.environ.setdefault('PYTORCH_CUDA_ALLOC_CONF', 'expandable_segments:True')
29
+ os.environ.setdefault('TOKENIZERS_PARALLELISM', 'false')
30
+
31
+ from sentence_transformers import SentenceTransformer, InputExample, losses
32
+ from huggingface_hub import hf_hub_download, HfApi
33
+
34
+ random.seed(42); np.random.seed(42); torch.manual_seed(42)
35
+ if torch.cuda.is_available(): torch.cuda.manual_seed_all(42)
36
+
37
+ # ---- Config ----
38
+ CFG = {
39
+ 'BASE_MODEL': 'intfloat/multilingual-e5-large',
40
+ 'BATCH_SIZE': 16, # OOM-safe
41
+ 'ENCODE_BATCH': 32,
42
+ 'EPOCHS': 2,
43
+ 'LEARNING_RATE': 2e-5,
44
+ 'WARMUP_RATIO': 0.1,
45
+ 'N_NEG_PER_POS': 4, # subsample 4 negs per positive (out of ~21 mined)
46
+ 'HF_REPO': 'Dharun72/swiss-legal-checkpoints',
47
+ 'OUT_DIR': Path('outputs_q3v1'),
48
+ 'PUSH_TO_HF': True,
49
+ 'VAL_SPLIT': 0.1, # hold out 10% of train for validation
50
+ }
51
+
52
+ DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
53
+ HF_TOKEN = os.environ.get('HF_TOKEN')
54
+ assert HF_TOKEN, 'HF_TOKEN env required'
55
+ CFG['OUT_DIR'].mkdir(exist_ok=True, parents=True)
56
+
57
+ print(f'torch {torch.__version__} cuda {torch.cuda.is_available()} device={DEVICE}')
58
+
59
+ # ---- Load data ----
60
+ def kaggle_download(competition='llm-agentic-legal-information-retrieval', target=Path('/tmp/kaggle')):
61
+ target.mkdir(exist_ok=True, parents=True)
62
+ if not (target / 'laws_de.csv').exists():
63
+ os.system(f'kaggle competitions download -c {competition} -p {target} -q && cd {target} && unzip -o -q *.zip')
64
+ return target
65
+
66
+ print('\n=== Loading data ===')
67
+ DATA_DIR = Path('/kaggle/input') if Path('/kaggle/input').exists() else kaggle_download()
68
+ print(f' data dir: {DATA_DIR}')
69
+
70
+ laws = pd.read_csv(DATA_DIR / 'laws_de.csv').fillna('')
71
+ laws_cits = laws['citation'].astype(str).tolist()
72
+ laws_text = laws['text'].astype(str).tolist()
73
+ print(f' laws_de: {len(laws_cits):,}')
74
+
75
+ train = pd.read_csv(DATA_DIR / 'train.csv').fillna('')
76
+ test = pd.read_csv(DATA_DIR / 'test.csv').fillna('')
77
+ print(f' train: {len(train)} test: {len(test)}')
78
+
79
+ # Pre-mined training data
80
+ print(' Downloading q3_train_v1.parquet from HF...')
81
+ parquet_path = hf_hub_download(repo_id=CFG['HF_REPO'], filename='q3_train_v1.parquet', token=HF_TOKEN)
82
+ df_pairs = pd.read_parquet(parquet_path)
83
+ print(f' loaded {len(df_pairs):,} (gold,neg-list) rows from {df_pairs["qid"].nunique()} unique queries')
84
+
85
+ # Hold out 10% of unique qids as val
86
+ all_qids = df_pairs['qid'].unique().tolist()
87
+ random.shuffle(all_qids)
88
+ n_val = int(len(all_qids) * CFG['VAL_SPLIT'])
89
+ val_qids = set(all_qids[:n_val])
90
+ train_qids = set(all_qids[n_val:])
91
+ print(f' train qids: {len(train_qids)} val qids: {len(val_qids)}')
92
+
93
+ # ---- Build training examples ----
94
+ print('\n=== Building training InputExamples ===')
95
+ Q_PFX = 'query: '
96
+ P_PFX = 'passage: '
97
+
98
+ cit_to_text = dict(zip(laws_cits, laws_text))
99
+ examples = []
100
+ for r in df_pairs.itertuples():
101
+ if r.qid not in train_qids: continue
102
+ neg_cits = list(r.hard_neg_cits)
103
+ if not neg_cits: continue
104
+ sampled = random.sample(neg_cits, min(CFG['N_NEG_PER_POS'], len(neg_cits)))
105
+ for nc in sampled:
106
+ nt = cit_to_text.get(nc, '')
107
+ if not nt: continue
108
+ examples.append(InputExample(texts=[Q_PFX + r.query, P_PFX + r.gold_text, P_PFX + nt]))
109
+
110
+ random.shuffle(examples)
111
+ print(f' total InputExamples: {len(examples):,}')
112
+
113
+ # Validation queries + their gold cits (for recall@40 eval)
114
+ val_qid_to_gold = defaultdict(set)
115
+ for r in df_pairs.itertuples():
116
+ if r.qid in val_qids:
117
+ val_qid_to_gold[r.qid].add(r.gold_cit)
118
+ val_queries = []
119
+ val_gold_sets = []
120
+ seen_qids = set()
121
+ for r in df_pairs.itertuples():
122
+ if r.qid in val_qids and r.qid not in seen_qids:
123
+ seen_qids.add(r.qid)
124
+ val_queries.append((r.qid, r.query))
125
+ val_gold_sets.append(val_qid_to_gold[r.qid])
126
+ print(f' val queries: {len(val_queries)}')
127
+
128
+ # ---- Load base model ----
129
+ print(f'\n=== Loading {CFG["BASE_MODEL"]} ===')
130
+ model = SentenceTransformer(CFG['BASE_MODEL'], device=DEVICE)
131
+ print(f' loaded. Max seq len: {model.max_seq_length}')
132
+
133
+ # Truncate to 256 for speed (queries + cit text usually < 256 tokens)
134
+ model.max_seq_length = 256
135
+
136
+ # ---- Eval baseline (before training) ----
137
+ def eval_recall_at_k(model, val_queries, val_gold_sets, k=40, chunk=8192):
138
+ """Manual recall@K — chunked corpus encoding to avoid OOM."""
139
+ # Encode corpus
140
+ laws_passages = [P_PFX + t for t in laws_text]
141
+ print(f' encoding {len(laws_passages):,} laws passages in chunks of {chunk}...')
142
+ all_embs = []
143
+ t0 = time.time()
144
+ for i in range(0, len(laws_passages), chunk):
145
+ embs = model.encode(laws_passages[i:i+chunk], batch_size=CFG['ENCODE_BATCH'],
146
+ convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False)
147
+ all_embs.append(embs)
148
+ if (i // chunk) % 4 == 0:
149
+ print(f' chunk {i:,}/{len(laws_passages):,} [{time.time()-t0:.0f}s]', flush=True)
150
+ laws_emb = np.vstack(all_embs).astype(np.float32)
151
+
152
+ # Encode queries
153
+ q_texts = [Q_PFX + q for _, q in val_queries]
154
+ q_emb = model.encode(q_texts, batch_size=CFG['ENCODE_BATCH'],
155
+ convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False)
156
+
157
+ # Cosine sims
158
+ sims = q_emb @ laws_emb.T
159
+
160
+ recalls = []
161
+ mean_ranks = []
162
+ for i, (qid, _) in enumerate(val_queries):
163
+ gold = val_gold_sets[i]
164
+ if not gold:
165
+ continue
166
+ top_k_idx = np.argpartition(-sims[i], k)[:k]
167
+ top_k_set = set(laws_cits[j] for j in top_k_idx)
168
+ recall = len(gold & top_k_set) / len(gold)
169
+ recalls.append(recall)
170
+
171
+ # Mean rank of gold cits
172
+ ranks = []
173
+ sorted_idx = np.argsort(-sims[i])
174
+ idx_to_rank = {laws_cits[j]: r for r, j in enumerate(sorted_idx[:1000])}
175
+ for g in gold:
176
+ ranks.append(idx_to_rank.get(g, 1000))
177
+ mean_ranks.append(np.mean(ranks))
178
+
179
+ return {
180
+ 'recall@k': np.mean(recalls),
181
+ 'mean_gold_rank': np.mean(mean_ranks),
182
+ 'n_val': len(recalls),
183
+ 'k': k,
184
+ }, laws_emb
185
+
186
+ print('\n=== Baseline eval (BEFORE training) ===')
187
+ torch.cuda.empty_cache(); gc.collect()
188
+ baseline, _ = eval_recall_at_k(model, val_queries, val_gold_sets, k=40)
189
+ print(f' Baseline recall@40 = {baseline["recall@k"]:.4f} mean_gold_rank = {baseline["mean_gold_rank"]:.1f}')
190
+
191
+ # Free baseline corpus
192
+ torch.cuda.empty_cache(); gc.collect()
193
+
194
+ # ---- Training ----
195
+ print('\n=== Training ===')
196
+ loss_fn = losses.MultipleNegativesRankingLoss(model)
197
+ loader = DataLoader(examples, batch_size=CFG['BATCH_SIZE'], shuffle=True, drop_last=True)
198
+ total_steps = len(loader) * CFG['EPOCHS']
199
+ warmup = int(CFG['WARMUP_RATIO'] * total_steps)
200
+ print(f' steps/epoch: {len(loader)} total_steps: {total_steps} warmup: {warmup}')
201
+
202
+ t0 = time.time()
203
+ model.fit(
204
+ train_objectives=[(loader, loss_fn)],
205
+ epochs=CFG['EPOCHS'],
206
+ warmup_steps=warmup,
207
+ optimizer_params={'lr': CFG['LEARNING_RATE']},
208
+ use_amp=True,
209
+ show_progress_bar=True,
210
+ output_path=str(CFG['OUT_DIR'] / 'checkpoint'),
211
+ )
212
+ print(f' Trained in {time.time()-t0:.0f}s')
213
+
214
+ # ---- Eval after training ----
215
+ print('\n=== Post-training eval ===')
216
+ torch.cuda.empty_cache(); gc.collect()
217
+ post, laws_emb_v2 = eval_recall_at_k(model, val_queries, val_gold_sets, k=40)
218
+ print(f' Post recall@40 = {post["recall@k"]:.4f} mean_gold_rank = {post["mean_gold_rank"]:.1f}')
219
+ print(f' Delta recall@40 = {post["recall@k"] - baseline["recall@k"]:+.4f}')
220
+
221
+ # ---- Encode test queries + train queries (for downstream use) ----
222
+ print('\n=== Encoding queries (test + train + val) for downstream ===')
223
+ test_q = test['query'].tolist()
224
+ train_q = train['query'].tolist()
225
+ val_q = [q for _, q in val_queries]
226
+
227
+ q_test_emb = model.encode([Q_PFX + q for q in test_q], batch_size=CFG['ENCODE_BATCH'], normalize_embeddings=True, convert_to_numpy=True, show_progress_bar=True)
228
+ q_train_emb = model.encode([Q_PFX + q for q in train_q], batch_size=CFG['ENCODE_BATCH'], normalize_embeddings=True, convert_to_numpy=True, show_progress_bar=True)
229
+ q_val_emb = model.encode([Q_PFX + q for q in val_q], batch_size=CFG['ENCODE_BATCH'], normalize_embeddings=True, convert_to_numpy=True, show_progress_bar=True)
230
+
231
+ # Save artifacts
232
+ print('\n=== Saving artifacts ===')
233
+ np.save(CFG['OUT_DIR'] / 'laws_emb_v2.npy', laws_emb_v2.astype(np.float32))
234
+ np.savez(CFG['OUT_DIR'] / 'queries_v2.npz',
235
+ test=q_test_emb.astype(np.float32),
236
+ train=q_train_emb.astype(np.float32),
237
+ val=q_val_emb.astype(np.float32),
238
+ val_qids=np.array([qid for qid, _ in val_queries]))
239
+ eval_data = {'baseline': baseline, 'post': post, 'cfg': {k: v for k, v in CFG.items() if k != 'OUT_DIR'}}
240
+ with open(CFG['OUT_DIR'] / 'q3_v1_eval.json', 'w') as f:
241
+ json.dump(eval_data, f, indent=2)
242
+ print(f' saved laws_emb_v2.npy, queries_v2.npz, q3_v1_eval.json to {CFG["OUT_DIR"]}')
243
+
244
+ # ---- Push to HF ----
245
+ if CFG['PUSH_TO_HF']:
246
+ print('\n=== Pushing to HF ===')
247
+ api = HfApi(token=HF_TOKEN)
248
+ for fn in ['laws_emb_v2.npy', 'queries_v2.npz', 'q3_v1_eval.json']:
249
+ api.upload_file(
250
+ path_or_fileobj=str(CFG['OUT_DIR'] / fn),
251
+ path_in_repo=fn,
252
+ repo_id=CFG['HF_REPO'],
253
+ repo_type='model',
254
+ commit_message=f'Q3 v1 — {fn}',
255
+ )
256
+ print(f' pushed {fn}')
257
+
258
+ print('\n=== DONE ===')
259
+ print(f'Baseline recall@40: {baseline["recall@k"]:.4f}')
260
+ print(f'Post recall@40: {post["recall@k"]:.4f}')
261
+ print(f'Delta: {post["recall@k"] - baseline["recall@k"]:+.4f}')