Dharun72 commited on
Commit
cdf6f6b
Β·
verified Β·
1 Parent(s): b8c8d6d

Q3 v1 notebook (tokens scrubbed)

Browse files
Files changed (1) hide show
  1. contrastive_finetune_e5_q3v1.ipynb +1305 -0
contrastive_finetune_e5_q3v1.ipynb ADDED
@@ -0,0 +1,1305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Contrastive Fine-Tune of multilingual-e5-large for Swiss Legal Citation Retrieval\n",
8
+ "\n",
9
+ "**Goal:** Improve dense retrieval F1 on the Kaggle LLM-Agentic-Legal-IR competition by fine-tuning the encoder on (English query β†’ Swiss German citation) pairs from `train.csv` with hard negatives mined from BM25 over `laws_de.csv`.\n",
10
+ "\n",
11
+ "**Why this and not the previous attempts:**\n",
12
+ "- Past LoRA fine-tunes used generic text MLM losses β†’ no improvement.\n",
13
+ "- This run uses **MultipleNegativesRankingLoss** with **hard negatives** specifically mined from our own pool (i.e. cits the model currently confuses with gold). This is the failure mode we want to fix, not generic semantic matching.\n",
14
+ "- Training pairs are **goal-aligned**: same format, same languages, same domain as test time.\n",
15
+ "\n",
16
+ "**Hard-negative quality safeguards** (NV-Retriever / GISTEmbedLoss style):\n",
17
+ "- Cross-query gold filter β€” exclude any cit that's gold for ANY train query\n",
18
+ "- Cosine-ratio false-neg drop β€” if `cos(q,neg) > 0.95 * max cos(q,gold)` it's likely a true gold leaking in\n",
19
+ "- Unique-qid batch sampler β€” no two examples in a batch share the same query (MNR loss hygiene)\n",
20
+ "\n",
21
+ "**Two-gate eval before re-encoding:**\n",
22
+ "- Gate A: Ξ”Recall@40 on val β‰₯ +0.01\n",
23
+ "- Gate B: mean gold rank in val retrieval improves (or holds)\n",
24
+ "\n",
25
+ "**Smoke tests at every stage** to catch silent failures (corrupted data, wrong prefixes, broken loss, frozen layers, etc.).\n",
26
+ "\n",
27
+ "**Expected runtime:** ~30-60 minutes on a single 24GB GPU (RTX 4090 / A6000 / A100).\n",
28
+ "\n",
29
+ "**Outputs:**\n",
30
+ "- `outputs/checkpoint_e5legal_v1/` β€” fine-tuned model directory\n",
31
+ "- `outputs/laws_emb_v1.npy` β€” re-encoded laws corpus (175,933 Γ— 1024)\n",
32
+ "- `outputs/queries_v1.npz` β€” re-encoded train/val/test queries\n",
33
+ "- `outputs/val_baseline_vs_finetuned.csv` β€” Recall@40 / F1 comparison\n"
34
+ ]
35
+ },
36
+ {
37
+ "cell_type": "markdown",
38
+ "metadata": {},
39
+ "source": [
40
+ "---\n",
41
+ "## Cell 1 β€” Install dependencies (Vast / Colab)"
42
+ ]
43
+ },
44
+ {
45
+ "cell_type": "code",
46
+ "execution_count": null,
47
+ "id": "98756acf",
48
+ "metadata": {},
49
+ "outputs": [],
50
+ "source": [
51
+ "import sys\n",
52
+ "# All deps in one shot β€” pinned for compatibility (ST 2.7.0 is last version where model.fit() works without datasets quirks)\n",
53
+ "!{sys.executable} -m pip install -q -U \"pandas>=2.0\" \"numpy>=1.24,<2.0\" \"scipy>=1.11\" \"scikit-learn>=1.3\" \"sentence-transformers==2.7.0\" \"transformers>=4.34,<4.45\" \"datasets>=2.18\" \"accelerate>=0.30\" \"huggingface_hub>=0.23\" \"bm25s>=0.2\" \"rank-bm25\" \"tqdm>=4.66\" \"kaggle\""
54
+ ]
55
+ },
56
+ {
57
+ "cell_type": "markdown",
58
+ "metadata": {},
59
+ "source": [
60
+ "## Cell 2 β€” Imports"
61
+ ]
62
+ },
63
+ {
64
+ "cell_type": "code",
65
+ "execution_count": null,
66
+ "metadata": {},
67
+ "outputs": [],
68
+ "source": [
69
+ "import os, sys, json, re, random, time, gc\n",
70
+ "from pathlib import Path\n",
71
+ "from collections import Counter, defaultdict\n",
72
+ "import numpy as np\n",
73
+ "import pandas as pd\n",
74
+ "import torch\n",
75
+ "from torch.utils.data import Dataset, DataLoader\n",
76
+ "from torch.nn import functional as F\n",
77
+ "\n",
78
+ "from transformers import AutoTokenizer, AutoModel\n",
79
+ "from sentence_transformers import SentenceTransformer, InputExample, losses, models\n",
80
+ "from sentence_transformers.evaluation import InformationRetrievalEvaluator\n",
81
+ "\n",
82
+ "from rank_bm25 import BM25Okapi\n",
83
+ "\n",
84
+ "random.seed(42); np.random.seed(42); torch.manual_seed(42)\n",
85
+ "if torch.cuda.is_available(): torch.cuda.manual_seed_all(42)\n",
86
+ "\n",
87
+ "print(f'torch {torch.__version__} cuda {torch.cuda.is_available()}')\n",
88
+ "print(f'sentence-transformers loaded')"
89
+ ]
90
+ },
91
+ {
92
+ "cell_type": "markdown",
93
+ "metadata": {},
94
+ "source": [
95
+ "## Cell 3 β€” SMOKE TEST 1: GPU + tiny model forward pass"
96
+ ]
97
+ },
98
+ {
99
+ "cell_type": "code",
100
+ "execution_count": null,
101
+ "metadata": {},
102
+ "outputs": [],
103
+ "source": [
104
+ "assert torch.cuda.is_available(), 'No GPU detected β€” fine-tuning will be too slow on CPU. Spin up Vast/Colab GPU instance.'\n",
105
+ "device = torch.device('cuda')\n",
106
+ "props = torch.cuda.get_device_properties(0)\n",
107
+ "print(f'GPU: {props.name} mem={props.total_memory/1e9:.1f} GB')\n",
108
+ "assert props.total_memory > 16e9, f'Need >=16GB VRAM, got {props.total_memory/1e9:.1f} GB'\n",
109
+ "\n",
110
+ "# Tiny smoke test: load smallest e5 model and encode 2 sentences\n",
111
+ "_t = AutoTokenizer.from_pretrained('intfloat/multilingual-e5-small')\n",
112
+ "_m = AutoModel.from_pretrained('intfloat/multilingual-e5-small').to(device).eval()\n",
113
+ "_inp = _t(['query: Was ist Vertragsrecht?', 'passage: Vertragsrecht regelt ...'],\n",
114
+ " padding=True, truncation=True, return_tensors='pt').to(device)\n",
115
+ "with torch.no_grad():\n",
116
+ " _h = _m(**_inp).last_hidden_state\n",
117
+ " # mean pool with mask\n",
118
+ " mask = _inp.attention_mask.unsqueeze(-1).float()\n",
119
+ " _emb = (_h * mask).sum(1) / mask.sum(1).clamp(min=1e-9)\n",
120
+ " _emb = F.normalize(_emb, p=2, dim=1)\n",
121
+ " cos = (_emb[0] @ _emb[1].T).item()\n",
122
+ "print(f'cos(query, passage) on smoke test = {cos:.3f} (expect ~0.7-0.95 β€” high since topically aligned)')\n",
123
+ "del _m, _t, _inp, _h, _emb; gc.collect(); torch.cuda.empty_cache()\n",
124
+ "print('SMOKE 1 PASS')"
125
+ ]
126
+ },
127
+ {
128
+ "cell_type": "markdown",
129
+ "metadata": {},
130
+ "source": [
131
+ "## Cell 4 β€” Config (edit these for your run)"
132
+ ]
133
+ },
134
+ {
135
+ "cell_type": "code",
136
+ "execution_count": null,
137
+ "id": "9533537c",
138
+ "metadata": {},
139
+ "outputs": [],
140
+ "source": [
141
+ "# === GUARDRAILS APPLIED ===\n",
142
+ "# (a) Hard-neg false-positive filter via baseline encoder cosine ratio\n",
143
+ "# (b) Batch sampler with unique qid per batch (MNR hygiene)\n",
144
+ "# (c) Two-gate eval: Ξ”Recall@40 β‰₯ +0.01 AND avg-gold-rank improves\n",
145
+ "\n",
146
+ "CFG = {\n",
147
+ " # Paths β€” auto-resolved by env detection cell below; these are placeholders\n",
148
+ " 'DATA_DIR': None, # set by env detection: /workspace/data on Vast, /kaggle/input/... on Kaggle, ./Data locally\n",
149
+ " 'OUTPUT_DIR': None, # set by env detection: /workspace/outputs on Vast, /kaggle/working on Kaggle, ./outputs locally\n",
150
+ "\n",
151
+ " # Base encoder. multilingual-e5-large is the default we already use.\n",
152
+ " # To start from a swiss-legal-finetuned checkpoint, point this at that path.\n",
153
+ " 'BASE_MODEL': 'intfloat/multilingual-e5-large',\n",
154
+ "\n",
155
+ " # Training\n",
156
+ " 'N_HARD_NEGS_PER_POS': 4, # mined hard negatives per (query, positive) pair\n",
157
+ " 'BM25_NEG_TOP_K': 50, # mine hard negs from top-K BM25 hits (excluding gold)\n",
158
+ " 'EPOCHS': 2, # 1-3 typical; watch val Recall@40 to pick\n",
159
+ " 'BATCH_SIZE': 16, # 16 fits e5-large training on 24GB GPU with AMP. Encoding uses ENCODE_BATCH separately.\n",
160
+ " 'LEARNING_RATE': 1.5e-5, # gentle β€” keep top blocks from drifting too far on 1k-pair training set\n",
161
+ " 'WARMUP_RATIO': 0.1,\n",
162
+ " 'MAX_LEN_QUERY': 160,\n",
163
+ " 'MAX_LEN_PASSAGE': 256,\n",
164
+ " 'GRADIENT_ACCUM': 1,\n",
165
+ " # Hard-negative quality filters (NV-Retriever / GISTEmbedLoss style)\n",
166
+ " 'FALSE_NEG_SIM_RATIO': 0.95, # drop neg if cos(q,neg) > 0.95 * max cos(q,gold)\n",
167
+ " 'BATCH_NO_DUP_QID': True, # batch sampler ensures unique qid per batch\n",
168
+ " 'CROSS_QUERY_GOLD_FILTER': True, # exclude any cit that is gold for ANY train query\n",
169
+ "\n",
170
+ " # Eval\n",
171
+ " 'VAL_RECALL_K': 40, # competition uses ~25-40 cit predictions per query\n",
172
+ " 'ENCODE_BATCH': 32, # 32 is safe on 24GB during inference. Increase to 64 if you have headroom.\n",
173
+ "\n",
174
+ " # Reproducibility\n",
175
+ " 'SEED': 42,\n",
176
+ "}\n",
177
+ "\n",
178
+ "# Reduce CUDA memory fragmentation. Only effective if set before any CUDA allocation,\n",
179
+ "# but harmless to set here too.\n",
180
+ "import os as _os\n",
181
+ "_os.environ.setdefault('PYTORCH_CUDA_ALLOC_CONF', 'expandable_segments:True')\n",
182
+ "\n",
183
+ "# OUTPUT_DIR mkdir happens in Cell 4.5 after env detection resolves the path.\n",
184
+ "print(json.dumps({k: str(v) if v is not None else None for k, v in CFG.items()}, indent=2))"
185
+ ]
186
+ },
187
+ {
188
+ "cell_type": "markdown",
189
+ "metadata": {},
190
+ "source": [
191
+ "## Cell 4.5 β€” Environment detection + Kaggle data download\n",
192
+ "\n",
193
+ "Auto-detect Kaggle / Vast / local. On Vast.ai or Colab without local data, pull the competition CSVs via the Kaggle API.\n",
194
+ "\n",
195
+ "**Set these as environment variables before launching this notebook** (Vast console β†’ Edit β†’ Environment):\n",
196
+ "- `KAGGLE_USERNAME` β€” your Kaggle username\n",
197
+ "- `KAGGLE_KEY` β€” your Kaggle API key (kaggle.com/settings β†’ API β†’ Create New Token)\n",
198
+ "- `HF_TOKEN` β€” your Hugging Face token (huggingface.co/settings/tokens)\n",
199
+ "\n",
200
+ "Inline fallbacks below for one-off runs β€” but rotate any leaked keys after the session."
201
+ ]
202
+ },
203
+ {
204
+ "cell_type": "code",
205
+ "execution_count": null,
206
+ "id": "bbcbf08f",
207
+ "metadata": {},
208
+ "outputs": [],
209
+ "source": [
210
+ "import os, subprocess\n",
211
+ "from pathlib import Path\n",
212
+ "\n",
213
+ "# ── Environment detection ──\n",
214
+ "IS_KAGGLE = os.path.exists('/kaggle')\n",
215
+ "IS_VASTAI = not IS_KAGGLE and os.path.exists('/workspace')\n",
216
+ "IS_LOCAL = not IS_KAGGLE and not IS_VASTAI\n",
217
+ "\n",
218
+ "print(f'Environment: KAGGLE={IS_KAGGLE} VASTAI={IS_VASTAI} LOCAL={IS_LOCAL}')\n",
219
+ "\n",
220
+ "# ── API keys ──\n",
221
+ "# Priority: existing ~/.kaggle/kaggle.json > env vars > inline fallback\n",
222
+ "_kaggle_json = Path.home() / '.kaggle' / 'kaggle.json'\n",
223
+ "if _kaggle_json.exists():\n",
224
+ " import json as _json\n",
225
+ " _creds = _json.loads(_kaggle_json.read_text())\n",
226
+ " os.environ['KAGGLE_USERNAME'] = _creds['username']\n",
227
+ " os.environ['KAGGLE_KEY'] = _creds['key']\n",
228
+ " print(f'Loaded creds from {_kaggle_json} (username={_creds[\"username\"]})')\n",
229
+ "else:\n",
230
+ " # Tokens must come from env vars or kaggle.json β€” never hardcode in notebook\n",
231
+ " if 'KAGGLE_KEY' not in os.environ or 'KAGGLE_USERNAME' not in os.environ:\n",
232
+ " raise RuntimeError(\"Set KAGGLE_USERNAME + KAGGLE_KEY env vars or place ~/.kaggle/kaggle.json before running\")\n",
233
+ "if 'HF_TOKEN' not in os.environ:\n",
234
+ " raise RuntimeError(\"Set HF_TOKEN env var before running\")\n",
235
+ "\n",
236
+ "# ── Resolve paths per environment ──\n",
237
+ "if IS_KAGGLE:\n",
238
+ " _ca = Path('/kaggle/input/competitions/llm-agentic-legal-information-retrieval')\n",
239
+ " _cb = Path('/kaggle/input/llm-agentic-legal-information-retrieval')\n",
240
+ " BASE = _ca if _ca.exists() else _cb\n",
241
+ " OUT_BASE = Path('/kaggle/working')\n",
242
+ "elif IS_VASTAI:\n",
243
+ " BASE = Path('/workspace/data')\n",
244
+ " OUT_BASE = Path('/workspace/outputs')\n",
245
+ "else:\n",
246
+ " BASE = Path('./Data')\n",
247
+ " OUT_BASE = Path('./outputs')\n",
248
+ "\n",
249
+ "CFG['DATA_DIR'] = str(BASE)\n",
250
+ "CFG['OUTPUT_DIR'] = str(OUT_BASE)\n",
251
+ "OUT_BASE.mkdir(parents=True, exist_ok=True)\n",
252
+ "print(f'BASE = {BASE}')\n",
253
+ "print(f'OUTPUT_DIR = {OUT_BASE}')\n",
254
+ "\n",
255
+ "# ── Download competition data on Vast if missing ──\n",
256
+ "if IS_VASTAI and not (BASE / 'laws_de.csv').exists():\n",
257
+ " assert os.environ.get('KAGGLE_KEY'), 'KAGGLE_KEY required to download competition data on Vast'\n",
258
+ " print('Downloading competition data via Kaggle API...')\n",
259
+ " kaggle_dir = Path.home() / '.kaggle'\n",
260
+ " kaggle_dir.mkdir(exist_ok=True)\n",
261
+ " # Only write kaggle.json if it doesn't exist (don't overwrite user's manual update)\n",
262
+ " kj = kaggle_dir / 'kaggle.json'\n",
263
+ " if not kj.exists():\n",
264
+ " kj.write_text(f'{{\"username\":\"{os.environ[\"KAGGLE_USERNAME\"]}\",\"key\":\"{os.environ[\"KAGGLE_KEY\"]}\"}}')\n",
265
+ " os.chmod(kj, 0o600)\n",
266
+ " BASE.mkdir(parents=True, exist_ok=True)\n",
267
+ " from kaggle.api.kaggle_api_extended import KaggleApi\n",
268
+ " api = KaggleApi(); api.authenticate()\n",
269
+ " api.competition_download_files('llm-agentic-legal-information-retrieval', path=str(BASE))\n",
270
+ " subprocess.check_call(['bash', '-c', f\"cd '{BASE}' && unzip -o '*.zip' && rm -f *.zip\"])\n",
271
+ " print(' download complete')\n",
272
+ "\n",
273
+ "# ── Verify required files ──\n",
274
+ "for f in ['laws_de.csv', 'train.csv', 'val.csv', 'test.csv']:\n",
275
+ " p = BASE / f\n",
276
+ " assert p.exists(), f'Missing {p} β€” set KAGGLE_KEY or copy data manually'\n",
277
+ " print(f' {f:<20s} {p.stat().st_size/1e6:>6.1f} MB')\n",
278
+ "\n",
279
+ "# ── Optional HF login ──\n",
280
+ "if os.environ.get('HF_TOKEN'):\n",
281
+ " from huggingface_hub import login\n",
282
+ " login(token=os.environ['HF_TOKEN'], add_to_git_credential=False)\n",
283
+ " print('HF logged in.')\n",
284
+ "\n",
285
+ "print('\\nSMOKE 1.5 PASS β€” environment + data ready')"
286
+ ]
287
+ },
288
+ {
289
+ "cell_type": "markdown",
290
+ "metadata": {},
291
+ "source": [
292
+ "## Cell 5 β€” Load data"
293
+ ]
294
+ },
295
+ {
296
+ "cell_type": "code",
297
+ "execution_count": null,
298
+ "metadata": {},
299
+ "outputs": [],
300
+ "source": [
301
+ "DATA = Path(CFG['DATA_DIR'])\n",
302
+ "OUT = Path(CFG['OUTPUT_DIR'])\n",
303
+ "\n",
304
+ "laws = pd.read_csv(DATA / 'laws_de.csv')\n",
305
+ "train = pd.read_csv(DATA / 'train.csv')\n",
306
+ "val = pd.read_csv(DATA / 'val.csv')\n",
307
+ "test = pd.read_csv(DATA / 'test.csv')\n",
308
+ "\n",
309
+ "# Sanity print\n",
310
+ "print(f'laws_de: {len(laws):,} rows cols={list(laws.columns)}')\n",
311
+ "print(f'train: {len(train):,} rows cols={list(train.columns)}')\n",
312
+ "print(f'val: {len(val):,} rows')\n",
313
+ "print(f'test: {len(test):,} rows')\n",
314
+ "print('\\n--- laws_de head ---')\n",
315
+ "print(laws.head(3))\n",
316
+ "print('\\n--- train head ---')\n",
317
+ "print(train.head(2))"
318
+ ]
319
+ },
320
+ {
321
+ "cell_type": "markdown",
322
+ "metadata": {},
323
+ "source": [
324
+ "## Cell 6 β€” SMOKE TEST 2: Schema + gold coverage"
325
+ ]
326
+ },
327
+ {
328
+ "cell_type": "code",
329
+ "execution_count": null,
330
+ "metadata": {},
331
+ "outputs": [],
332
+ "source": [
333
+ "# Verify schemas\n",
334
+ "for df, name, expected in [\n",
335
+ " (laws, 'laws_de', ['citation','text']),\n",
336
+ " (train, 'train', ['query_id','query','gold_citations']),\n",
337
+ " (val, 'val', ['query_id','query','gold_citations']),\n",
338
+ " (test, 'test', ['query_id','query']),\n",
339
+ "]:\n",
340
+ " missing = [c for c in expected if c not in df.columns]\n",
341
+ " assert not missing, f'{name}: missing cols {missing}'\n",
342
+ "\n",
343
+ "# Gold coverage check: how many train gold cits are present in laws_de?\n",
344
+ "laws_cit_set = set(laws['citation'].astype(str))\n",
345
+ "print(f'laws_de unique cits: {len(laws_cit_set):,}')\n",
346
+ "\n",
347
+ "all_gold = []\n",
348
+ "for s in train['gold_citations']:\n",
349
+ " all_gold.extend([c.strip() for c in str(s).split(';') if c.strip()])\n",
350
+ "all_gold_set = set(all_gold)\n",
351
+ "covered = sum(1 for c in all_gold_set if c in laws_cit_set)\n",
352
+ "print(f'train gold unique cits: {len(all_gold_set):,} covered_in_laws: {covered:,} ({100*covered/len(all_gold_set):.1f}%)')\n",
353
+ "\n",
354
+ "all_gold_val = []\n",
355
+ "for s in val['gold_citations']:\n",
356
+ " all_gold_val.extend([c.strip() for c in str(s).split(';') if c.strip()])\n",
357
+ "all_gold_val_set = set(all_gold_val)\n",
358
+ "covered_val = sum(1 for c in all_gold_val_set if c in laws_cit_set)\n",
359
+ "print(f'val gold unique cits: {len(all_gold_val_set):,} covered_in_laws: {covered_val:,} ({100*covered_val/len(all_gold_val_set):.1f}%)')\n",
360
+ "\n",
361
+ "# Court cits won't be in laws_de β€” that's expected.\n",
362
+ "ART_PAT = re.compile(r'^Art\\.\\s*\\d+[a-z]?')\n",
363
+ "court_in_train = sum(1 for c in all_gold_set if not ART_PAT.match(c))\n",
364
+ "print(f'court cits in train gold (ART_PAT no match): {court_in_train}')\n",
365
+ "print('SMOKE 2 PASS β€” schema OK, gold coverage measured')"
366
+ ]
367
+ },
368
+ {
369
+ "cell_type": "markdown",
370
+ "metadata": {},
371
+ "source": [
372
+ "## Cell 7 β€” Build (query, positive_passage) pairs\n",
373
+ "\n",
374
+ "For each train query, expand its `gold_citations` into individual positive pairs `(query_text, citation_text)`. We use **only law citations** present in `laws_de` for fine-tuning the law encoder. Court cits are excluded here (they live in a separate corpus and would require a court-specific re-encoding loop)."
375
+ ]
376
+ },
377
+ {
378
+ "cell_type": "code",
379
+ "execution_count": null,
380
+ "metadata": {},
381
+ "outputs": [],
382
+ "source": [
383
+ "cit_to_text = dict(zip(laws['citation'].astype(str), laws['text'].astype(str)))\n",
384
+ "\n",
385
+ "pairs = [] # list of (query_text, citation, citation_text, query_id)\n",
386
+ "for _, r in train.iterrows():\n",
387
+ " q = str(r['query']).strip()\n",
388
+ " qid = r['query_id']\n",
389
+ " for c in str(r['gold_citations']).split(';'):\n",
390
+ " c = c.strip()\n",
391
+ " if not c: continue\n",
392
+ " if c in cit_to_text:\n",
393
+ " pairs.append((q, c, cit_to_text[c], qid))\n",
394
+ "\n",
395
+ "print(f'(query, positive_law) pairs: {len(pairs):,}')\n",
396
+ "print('\\nSample pair:')\n",
397
+ "p = pairs[0]\n",
398
+ "print(f' qid: {p[3]}\\n query: {p[0][:150]}...\\n cit: {p[1]}\\n text: {p[2][:200]}...')"
399
+ ]
400
+ },
401
+ {
402
+ "cell_type": "markdown",
403
+ "metadata": {},
404
+ "source": [
405
+ "## Cell 8 β€” SMOKE TEST 3: Pair sanity"
406
+ ]
407
+ },
408
+ {
409
+ "cell_type": "code",
410
+ "execution_count": null,
411
+ "metadata": {},
412
+ "outputs": [],
413
+ "source": [
414
+ "assert len(pairs) > 1000, f'Too few pairs ({len(pairs)}) β€” check gold coverage'\n",
415
+ "for q, c, t, qid in pairs[:3]:\n",
416
+ " assert isinstance(q, str) and len(q) > 5\n",
417
+ " assert isinstance(c, str) and ART_PAT.match(c) is not None, f'unexpected cit format: {c}'\n",
418
+ " assert isinstance(t, str) and len(t) > 5\n",
419
+ "# Distribution of golds per query\n",
420
+ "g_per_q = Counter(p[3] for p in pairs)\n",
421
+ "print(f'queries with >=1 law gold: {len(g_per_q):,}')\n",
422
+ "print(f'mean golds per query (in laws): {np.mean(list(g_per_q.values())):.1f}')\n",
423
+ "print('SMOKE 3 PASS')"
424
+ ]
425
+ },
426
+ {
427
+ "cell_type": "markdown",
428
+ "metadata": {},
429
+ "source": [
430
+ "## Cell 9 β€” Build BM25 index over laws_de for hard negative mining"
431
+ ]
432
+ },
433
+ {
434
+ "cell_type": "code",
435
+ "metadata": {},
436
+ "execution_count": null,
437
+ "outputs": [],
438
+ "source": [
439
+ "# Q3 v1 β€” Load pre-mined hard negatives (4-source mining done locally)\n",
440
+ "# Replaces the original BM25 mining cells. Keeps `pairs` from earlier cell.\n",
441
+ "# Sources: BM25, diff-code, adjacent, diff-paragraph (with co-citation validity check).\n",
442
+ "\n",
443
+ "from huggingface_hub import hf_hub_download\n",
444
+ "import pandas as pd\n",
445
+ "import os\n",
446
+ "\n",
447
+ "HF_TOKEN = os.environ.get('HF_TOKEN')\n",
448
+ "parquet_path = hf_hub_download(\n",
449
+ " repo_id='Dharun72/swiss-legal-checkpoints',\n",
450
+ " filename='q3_train_v1.parquet',\n",
451
+ " token=HF_TOKEN,\n",
452
+ ")\n",
453
+ "df_pairs = pd.read_parquet(parquet_path)\n",
454
+ "print(f'Loaded q3_train_v1.parquet: {len(df_pairs):,} rows from {df_pairs[\"qid\"].nunique()} unique queries')\n",
455
+ "\n",
456
+ "# qid_to_query_text β€” needed by smoke + filter cells downstream\n",
457
+ "qid_to_query_text = dict(zip(train['query_id'], train['query']))\n",
458
+ "\n",
459
+ "# qid_to_golds β€” needed by smoke + filter cells downstream\n",
460
+ "qid_to_golds = {}\n",
461
+ "for r in df_pairs.itertuples():\n",
462
+ " qid_to_golds.setdefault(r.qid, set()).add(r.gold_cit)\n",
463
+ "\n",
464
+ "# qid_to_negidx β€” list of laws DataFrame row indices for hard negatives\n",
465
+ "qid_to_negidx = {}\n",
466
+ "for r in df_pairs.itertuples():\n",
467
+ " qid_to_negidx.setdefault(r.qid, []).extend(list(r.hard_neg_idxs))\n",
468
+ "# Dedupe within each qid\n",
469
+ "for qid in qid_to_negidx:\n",
470
+ " qid_to_negidx[qid] = list(dict.fromkeys(qid_to_negidx[qid]))\n",
471
+ "print(f'qid_to_negidx: {len(qid_to_negidx)} qids, mean {sum(len(v) for v in qid_to_negidx.values())/len(qid_to_negidx):.1f} negs/qid')\n",
472
+ "\n",
473
+ "# laws_cits_arr used downstream by smoke-test cell\n",
474
+ "laws_cits_arr = laws[\"citation\"].astype(str).values\n",
475
+ "\n",
476
+ "# N_NEG and TOP_K used downstream\n",
477
+ "N_NEG = CFG.get('N_HARD_NEGS_PER_POS', 4)\n",
478
+ "TOP_K = max(len(v) for v in qid_to_negidx.values())\n",
479
+ "print(f'N_NEG={N_NEG} TOP_K (max negs per qid)={TOP_K}')\n",
480
+ "\n",
481
+ "# Smoke A: validate that no negatives are gold for that qid\n",
482
+ "n_violations = 0\n",
483
+ "for qid, negs in qid_to_negidx.items():\n",
484
+ " golds = qid_to_golds.get(qid, set())\n",
485
+ " for ni in negs:\n",
486
+ " if laws_cits_arr[ni] in golds:\n",
487
+ " n_violations += 1\n",
488
+ "print(f'Smoke A: gold-leak in negatives = {n_violations} (must be 0)')\n",
489
+ "assert n_violations == 0, \"Hard negative is in gold β€” bug\"\n",
490
+ "print('Smoke A PASS β€” no gold leakage in mined negatives')\n"
491
+ ]
492
+ },
493
+ {
494
+ "cell_type": "markdown",
495
+ "metadata": {},
496
+ "source": [
497
+ "## Cell 11 β€” SMOKE TEST 4: Hard negatives are non-gold and informative"
498
+ ]
499
+ },
500
+ {
501
+ "cell_type": "code",
502
+ "execution_count": null,
503
+ "metadata": {},
504
+ "outputs": [],
505
+ "source": [
506
+ "import random as _random\n",
507
+ "_random.seed(7)\n",
508
+ "sample_qids = _random.sample(list(qid_to_golds.keys()), 3)\n",
509
+ "for qid in sample_qids:\n",
510
+ " golds = qid_to_golds[qid]\n",
511
+ " negs = qid_to_negidx[qid][:N_NEG]\n",
512
+ " print(f'\\n=== {qid} ===')\n",
513
+ " print(f' query: {train[train.query_id==qid].iloc[0][\"query\"][:200]}...')\n",
514
+ " print(f' golds: {list(golds)[:3]}')\n",
515
+ " for ni in negs:\n",
516
+ " nc = laws_cits_arr[ni]\n",
517
+ " nt = laws.iloc[ni][\"text\"][:120]\n",
518
+ " assert nc not in golds, f'NEG {nc} found in gold of {qid} β€” bug'\n",
519
+ " print(f' neg: {nc} text: {nt}...')\n",
520
+ "print('\\nSMOKE 4 PASS β€” hard negatives valid (non-gold, BM25-similar)')"
521
+ ]
522
+ },
523
+ {
524
+ "cell_type": "markdown",
525
+ "metadata": {},
526
+ "source": [
527
+ "## Cell 11.5 β€” SMOKE TEST 4.5: False-negative filter via baseline encoder cosine ratio\n",
528
+ "\n",
529
+ "Per NV-Retriever / GISTEmbedLoss best practice: a hard negative whose embedding is suspiciously close to gold is probably ITSELF a near-gold (paragraph variant, sibling article, semantic duplicate). Training against it punishes the model for being correct.\n",
530
+ "\n",
531
+ "We filter using the BASE encoder's cosine: drop any candidate where `cos(query, neg) > FALSE_NEG_SIM_RATIO * max cos(query, gold)`."
532
+ ]
533
+ },
534
+ {
535
+ "cell_type": "code",
536
+ "execution_count": null,
537
+ "id": "1eefd5c3",
538
+ "metadata": {},
539
+ "outputs": [],
540
+ "source": [
541
+ "# e5 prefix conventions β€” used throughout (defined here so earlier cells can reference)\n",
542
+ "Q_PFX = 'query: '\n",
543
+ "P_PFX = 'passage: '\n",
544
+ "\n",
545
+ "# Helper: aggressively release a GPU model. PyTorch's caching allocator holds\n",
546
+ "# memory after del; moving to cpu first FORCES freelist release.\n",
547
+ "def _free_gpu_model(*objs):\n",
548
+ " for o in objs:\n",
549
+ " try:\n",
550
+ " o.to('cpu')\n",
551
+ " except Exception:\n",
552
+ " pass\n",
553
+ " for o in objs:\n",
554
+ " del o\n",
555
+ " gc.collect(); gc.collect()\n",
556
+ " if torch.cuda.is_available():\n",
557
+ " torch.cuda.empty_cache()\n",
558
+ " torch.cuda.synchronize()\n",
559
+ " torch.cuda.empty_cache()\n",
560
+ " try: torch.cuda.reset_peak_memory_stats()\n",
561
+ " except Exception: pass\n",
562
+ "\n",
563
+ "# Need the model loaded before filtering β€” load it now (will be RELEASED before training)\n",
564
+ "print(f'Loading base model for filter: {CFG[\"BASE_MODEL\"]}')\n",
565
+ "word_emb_filter = models.Transformer(CFG['BASE_MODEL'], max_seq_length=CFG['MAX_LEN_PASSAGE'])\n",
566
+ "pool_filter = models.Pooling(word_emb_filter.get_word_embedding_dimension(), pooling_mode='mean')\n",
567
+ "norm_filter = models.Normalize()\n",
568
+ "_filter_model = SentenceTransformer(modules=[word_emb_filter, pool_filter, norm_filter], device='cuda')\n",
569
+ "print(f' loaded.')\n",
570
+ "\n",
571
+ "# 1) Encode unique queries\n",
572
+ "unique_qids = list(qid_to_negidx.keys())\n",
573
+ "qtxts = {qid: qid_to_query_text[qid] for qid in unique_qids}\n",
574
+ "\n",
575
+ "print(f'Encoding {len(unique_qids):,} queries...')\n",
576
+ "q_embs_arr = _filter_model.encode(\n",
577
+ " [Q_PFX + str(qtxts[qid]) for qid in unique_qids],\n",
578
+ " batch_size=CFG['ENCODE_BATCH'], convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=True,\n",
579
+ ")\n",
580
+ "qid_to_qemb = {qid: q_embs_arr[i] for i, qid in enumerate(unique_qids)}\n",
581
+ "\n",
582
+ "# 2) Collect cits we need to encode (golds + candidate negs)\n",
583
+ "needed_cits = set()\n",
584
+ "for qid in unique_qids:\n",
585
+ " for g in qid_to_golds[qid]:\n",
586
+ " if g in cit_to_text: needed_cits.add(g)\n",
587
+ " for ni in qid_to_negidx[qid]:\n",
588
+ " needed_cits.add(laws_cits_arr[ni])\n",
589
+ "needed_cits = sorted(needed_cits)\n",
590
+ "print(f'Encoding {len(needed_cits):,} unique cit texts...')\n",
591
+ "\n",
592
+ "cit_embs_arr = _filter_model.encode(\n",
593
+ " [P_PFX + cit_to_text.get(c, c) for c in needed_cits],\n",
594
+ " batch_size=CFG['ENCODE_BATCH'] * 2, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=True,\n",
595
+ ")\n",
596
+ "cit_to_emb_idx = {c: i for i, c in enumerate(needed_cits)}\n",
597
+ "\n",
598
+ "# 3) Filter β€” drop any neg with sim ratio > threshold\n",
599
+ "ratio = CFG['FALSE_NEG_SIM_RATIO']\n",
600
+ "qid_to_negidx_filt = {}\n",
601
+ "n_kept_total, n_dropped_total = 0, 0\n",
602
+ "margin_examples = []\n",
603
+ "for qid in unique_qids:\n",
604
+ " qe = qid_to_qemb[qid]\n",
605
+ " golds_present = [g for g in qid_to_golds[qid] if g in cit_to_emb_idx]\n",
606
+ " if not golds_present:\n",
607
+ " continue\n",
608
+ " pos_sims = np.array([float(qe @ cit_embs_arr[cit_to_emb_idx[g]]) for g in golds_present])\n",
609
+ " pos_max = float(pos_sims.max())\n",
610
+ " threshold = ratio * pos_max\n",
611
+ "\n",
612
+ " keep = []\n",
613
+ " for ni in qid_to_negidx[qid]:\n",
614
+ " nc = laws_cits_arr[ni]\n",
615
+ " ne = cit_embs_arr[cit_to_emb_idx[nc]]\n",
616
+ " neg_sim = float(qe @ ne)\n",
617
+ " if neg_sim > threshold:\n",
618
+ " n_dropped_total += 1\n",
619
+ " if len(margin_examples) < 8:\n",
620
+ " margin_examples.append((qid, nc, neg_sim, pos_max, threshold))\n",
621
+ " continue\n",
622
+ " keep.append(ni)\n",
623
+ " n_kept_total += 1\n",
624
+ " if len(keep) >= N_NEG: break\n",
625
+ " if keep:\n",
626
+ " qid_to_negidx_filt[qid] = keep\n",
627
+ "\n",
628
+ "# Replace\n",
629
+ "qid_to_negidx = qid_to_negidx_filt\n",
630
+ "print(f'\\nFilter results:')\n",
631
+ "print(f' kept: {n_kept_total:,} dropped_as_likely_falsenegs: {n_dropped_total:,} ({100*n_dropped_total/max(1,n_kept_total+n_dropped_total):.1f}%)')\n",
632
+ "print(f' queries surviving with >=1 neg: {len(qid_to_negidx):,}/{len(unique_qids):,}')\n",
633
+ "\n",
634
+ "print(f'\\nSample dropped negatives (qid, cit, sim_neg, sim_pos_max, threshold):')\n",
635
+ "for qid, nc, ns, pm, th in margin_examples:\n",
636
+ " print(f' {qid} {nc:<35s} neg_sim={ns:.3f} pos_max={pm:.3f} threshold={th:.3f}')\n",
637
+ "\n",
638
+ "# Aggressive GPU release β€” move model off GPU then drop refs\n",
639
+ "_free_gpu_model(_filter_model)\n",
640
+ "del word_emb_filter, pool_filter, norm_filter, q_embs_arr, cit_embs_arr\n",
641
+ "gc.collect(); torch.cuda.empty_cache()\n",
642
+ "\n",
643
+ "if torch.cuda.is_available():\n",
644
+ " free_gb, total_gb = (x/1e9 for x in torch.cuda.mem_get_info())\n",
645
+ " print(f'\\nGPU after filter cleanup: free={free_gb:.2f} GB / {total_gb:.2f} GB')\n",
646
+ "print('SMOKE 4.5 PASS β€” false-negative filter applied')"
647
+ ]
648
+ },
649
+ {
650
+ "cell_type": "markdown",
651
+ "metadata": {},
652
+ "source": [
653
+ "## Cell 12 β€” Build training InputExamples"
654
+ ]
655
+ },
656
+ {
657
+ "cell_type": "code",
658
+ "execution_count": null,
659
+ "id": "f7788468",
660
+ "metadata": {},
661
+ "outputs": [],
662
+ "source": [
663
+ "# sentence-transformers e5 prefixes:\n",
664
+ "# query: queries get \"query: \" prefix\n",
665
+ "# passage: passages/citations get \"passage: \" prefix\n",
666
+ "Q_PFX = 'query: '\n",
667
+ "P_PFX = 'passage: '\n",
668
+ "\n",
669
+ "examples = []\n",
670
+ "example_qids = [] # parallel array β€” used by NoDupQidBatchSampler later\n",
671
+ "for q, c, t, qid in pairs:\n",
672
+ " neg_idxs = qid_to_negidx.get(qid, [])\n",
673
+ " if not neg_idxs: continue\n",
674
+ " sampled = _random.sample(neg_idxs, min(N_NEG, len(neg_idxs)))\n",
675
+ " for ni in sampled:\n",
676
+ " nt = str(laws.iloc[ni]['text'])\n",
677
+ " # MultipleNegativesRankingLoss with explicit hard neg: triplet [anchor, positive, neg]\n",
678
+ " examples.append(InputExample(texts=[Q_PFX + q, P_PFX + t, P_PFX + nt]))\n",
679
+ " example_qids.append(qid)\n",
680
+ "\n",
681
+ "assert len(examples) == len(example_qids)\n",
682
+ "print(f'Training InputExamples: {len(examples):,} (unique qids: {len(set(example_qids)):,})')\n",
683
+ "print(f' Anchor (sample): {examples[0].texts[0][:80]}...')\n",
684
+ "print(f' Positive (sample): {examples[0].texts[1][:80]}...')\n",
685
+ "print(f' Hard neg (sample): {examples[0].texts[2][:80]}...')"
686
+ ]
687
+ },
688
+ {
689
+ "cell_type": "markdown",
690
+ "metadata": {},
691
+ "source": [
692
+ "## Cell 13 β€” Load base encoder as SentenceTransformer"
693
+ ]
694
+ },
695
+ {
696
+ "cell_type": "code",
697
+ "execution_count": null,
698
+ "metadata": {},
699
+ "outputs": [],
700
+ "source": [
701
+ "print(f'Loading base model: {CFG[\"BASE_MODEL\"]}')\n",
702
+ "word_emb = models.Transformer(CFG['BASE_MODEL'], max_seq_length=CFG['MAX_LEN_PASSAGE'])\n",
703
+ "pool = models.Pooling(word_emb.get_word_embedding_dimension(), pooling_mode='mean')\n",
704
+ "norm = models.Normalize() # L2 normalize for cosine\n",
705
+ "model = SentenceTransformer(modules=[word_emb, pool, norm], device='cuda')\n",
706
+ "print(f'Loaded. embedding_dim={word_emb.get_word_embedding_dimension()} device={model.device}')"
707
+ ]
708
+ },
709
+ {
710
+ "cell_type": "markdown",
711
+ "metadata": {},
712
+ "source": [
713
+ "## Cell 14 β€” SMOKE TEST 5: Single forward, check output shape + cosine"
714
+ ]
715
+ },
716
+ {
717
+ "cell_type": "code",
718
+ "execution_count": null,
719
+ "metadata": {},
720
+ "outputs": [],
721
+ "source": [
722
+ "_q = examples[0].texts[0]\n",
723
+ "_p = examples[0].texts[1]\n",
724
+ "_n = examples[0].texts[2]\n",
725
+ "_emb = model.encode([_q, _p, _n], convert_to_tensor=True, normalize_embeddings=True)\n",
726
+ "print(f' emb shape: {_emb.shape}')\n",
727
+ "cos_qp = (_emb[0] @ _emb[1].T).item()\n",
728
+ "cos_qn = (_emb[0] @ _emb[2].T).item()\n",
729
+ "print(f' baseline cos(Q, Pos) = {cos_qp:.4f}')\n",
730
+ "print(f' baseline cos(Q, Neg) = {cos_qn:.4f}')\n",
731
+ "print(f' margin (pos-neg) = {cos_qp - cos_qn:+.4f} (training should INCREASE this)')\n",
732
+ "assert _emb.shape[1] >= 768\n",
733
+ "print('SMOKE 5 PASS')"
734
+ ]
735
+ },
736
+ {
737
+ "cell_type": "markdown",
738
+ "metadata": {},
739
+ "source": [
740
+ "## Cell 15 β€” Build val IR evaluator (Recall@K on val set)"
741
+ ]
742
+ },
743
+ {
744
+ "cell_type": "code",
745
+ "execution_count": null,
746
+ "id": "6375fa43",
747
+ "metadata": {},
748
+ "outputs": [],
749
+ "source": [
750
+ "# Manual eval β€” replaces sentence_transformers.InformationRetrievalEvaluator.\n",
751
+ "# Why not the ST evaluator?\n",
752
+ "# 1. ST 2.7's IR evaluator returns 0.0000 for the primary metric (known regression).\n",
753
+ "# 2. It encodes the full 175k corpus on GPU and holds those tensors after the call,\n",
754
+ "# causing OOM on 24GB cards before training even starts.\n",
755
+ "# This function encodes corpus in chunks, computes ranks on CPU, and releases GPU\n",
756
+ "# state immediately after each call.\n",
757
+ "\n",
758
+ "# Pre-compute val gold sets restricted to laws_de (court cits dropped β€” they live\n",
759
+ "# in court_considerations.csv which we don't fine-tune here).\n",
760
+ "val_qtexts = [Q_PFX + str(q) for q in val['query'].tolist()]\n",
761
+ "val_gold_idx = [] # list[set[int]] aligned with val rows: indices into laws_cits_arr\n",
762
+ "for _, r in val.iterrows():\n",
763
+ " s = set()\n",
764
+ " for c in str(r['gold_citations']).split(';'):\n",
765
+ " c = c.strip()\n",
766
+ " if c in cit_to_idx:\n",
767
+ " s.add(cit_to_idx[c])\n",
768
+ " val_gold_idx.append(s)\n",
769
+ "\n",
770
+ "n_val_gold = sum(len(s) for s in val_gold_idx)\n",
771
+ "print(f'Val: {len(val_qtexts)} queries, corpus {len(laws_cits_arr):,} laws, {n_val_gold} law-gold cits total')\n",
772
+ "\n",
773
+ "\n",
774
+ "def encode_corpus_to_cpu(_model, batch_size=None, desc='corpus'):\n",
775
+ " \"\"\"Encode laws corpus in chunks; return float32 numpy on CPU.\n",
776
+ " Releases per-chunk GPU tensors aggressively to avoid 24GB OOM.\"\"\"\n",
777
+ " bs = batch_size or CFG['ENCODE_BATCH']\n",
778
+ " out = np.empty((len(laws_cits_arr), 1024), dtype=np.float32)\n",
779
+ " chunk = 8192\n",
780
+ " texts_all = laws['text'].astype(str).tolist()\n",
781
+ " t0 = time.time()\n",
782
+ " for s in range(0, len(texts_all), chunk):\n",
783
+ " e = min(s + chunk, len(texts_all))\n",
784
+ " emb = _model.encode(\n",
785
+ " [P_PFX + t for t in texts_all[s:e]],\n",
786
+ " batch_size=bs,\n",
787
+ " convert_to_numpy=True, normalize_embeddings=True,\n",
788
+ " show_progress_bar=False,\n",
789
+ " )\n",
790
+ " out[s:e] = emb\n",
791
+ " if torch.cuda.is_available():\n",
792
+ " torch.cuda.empty_cache()\n",
793
+ " if s % (chunk * 4) == 0:\n",
794
+ " print(f' {desc} {e}/{len(texts_all)} [{time.time()-t0:.0f}s]', flush=True)\n",
795
+ " return out\n",
796
+ "\n",
797
+ "\n",
798
+ "def eval_recall_and_rank(_model, k=40, label='', encode_bs=None):\n",
799
+ " \"\"\"Manual val Recall@K + mean gold rank. Returns (recall_at_k, mean_rank, median_rank).\n",
800
+ " Memory-conscious: encodes corpus in chunks, computes per-query similarity on CPU.\"\"\"\n",
801
+ " print(f'\\n[eval] {label}: encoding corpus...', flush=True)\n",
802
+ " cemb = encode_corpus_to_cpu(_model, batch_size=encode_bs, desc='laws')\n",
803
+ "\n",
804
+ " print(f'[eval] {label}: encoding {len(val_qtexts)} val queries...', flush=True)\n",
805
+ " qemb = _model.encode(\n",
806
+ " val_qtexts, batch_size=CFG['ENCODE_BATCH'],\n",
807
+ " convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False,\n",
808
+ " ).astype(np.float32)\n",
809
+ "\n",
810
+ " if torch.cuda.is_available():\n",
811
+ " torch.cuda.empty_cache()\n",
812
+ "\n",
813
+ " # Per-query similarity + rank\n",
814
+ " recalls, ranks = [], []\n",
815
+ " for i, gold_set in enumerate(val_gold_idx):\n",
816
+ " if not gold_set:\n",
817
+ " recalls.append(0.0); continue\n",
818
+ " sims = qemb[i] @ cemb.T # (N_corpus,)\n",
819
+ " # Top-K ids\n",
820
+ " topk = np.argpartition(-sims, k)[:k]\n",
821
+ " topk_set = set(int(x) for x in topk)\n",
822
+ " hit = len(gold_set & topk_set)\n",
823
+ " recalls.append(hit / len(gold_set))\n",
824
+ " # Ranks of gold\n",
825
+ " order = np.argsort(-sims)\n",
826
+ " rank_of = {int(x): r for r, x in enumerate(order)}\n",
827
+ " for g in gold_set:\n",
828
+ " ranks.append(rank_of[g])\n",
829
+ "\n",
830
+ " mean_recall = float(np.mean(recalls)) if recalls else 0.0\n",
831
+ " mean_rank = float(np.mean(ranks)) if ranks else float('inf')\n",
832
+ " median_rank = float(np.median(ranks)) if ranks else float('inf')\n",
833
+ " print(f'[eval] {label}: Recall@{k}={mean_recall:.4f} mean_rank={mean_rank:.0f} median_rank={median_rank:.0f}')\n",
834
+ " # Free corpus embedding immediately\n",
835
+ " del cemb, qemb\n",
836
+ " gc.collect()\n",
837
+ " return mean_recall, mean_rank, median_rank"
838
+ ]
839
+ },
840
+ {
841
+ "cell_type": "markdown",
842
+ "metadata": {},
843
+ "source": [
844
+ "## Cell 16 β€” SMOKE TEST 6: Baseline val Recall@40 (no fine-tune)"
845
+ ]
846
+ },
847
+ {
848
+ "cell_type": "code",
849
+ "execution_count": null,
850
+ "id": "bf1a37ad",
851
+ "metadata": {},
852
+ "outputs": [],
853
+ "source": [
854
+ "print('Computing BASELINE val IR metrics (pre-fine-tune)...')\n",
855
+ "t0 = time.time()\n",
856
+ "baseline_recall40, mean_r_base, med_r_base = eval_recall_and_rank(\n",
857
+ " model, k=CFG['VAL_RECALL_K'], label='baseline'\n",
858
+ ")\n",
859
+ "print(f' total baseline eval: {time.time()-t0:.1f}s')\n",
860
+ "print(f'\\nBaseline (multilingual-e5-large, no fine-tune):')\n",
861
+ "print(f' Recall@{CFG[\"VAL_RECALL_K\"]} = {baseline_recall40:.4f}')\n",
862
+ "print(f' mean_rank = {mean_r_base:.0f}')\n",
863
+ "print(f' median_rank = {med_r_base:.0f}')\n",
864
+ "\n",
865
+ "# Free any GPU residue from corpus encoding before training starts\n",
866
+ "gc.collect()\n",
867
+ "if torch.cuda.is_available():\n",
868
+ " torch.cuda.empty_cache()\n",
869
+ " free_gb, total_gb = (x/1e9 for x in torch.cuda.mem_get_info())\n",
870
+ " print(f' GPU after baseline: free={free_gb:.2f} GB / {total_gb:.2f} GB')\n",
871
+ "print('\\nSMOKE 6 PASS β€” record this baseline. Fine-tune must improve it.')"
872
+ ]
873
+ },
874
+ {
875
+ "cell_type": "markdown",
876
+ "id": "af0558ec",
877
+ "metadata": {},
878
+ "source": [
879
+ "## Cell 17 β€” SMOKE TEST 7: Mini-train (50 steps), confirm loss decreases"
880
+ ]
881
+ },
882
+ {
883
+ "cell_type": "code",
884
+ "execution_count": null,
885
+ "id": "9c2f432c",
886
+ "metadata": {},
887
+ "outputs": [],
888
+ "source": [
889
+ "# Aggressive cleanup BEFORE fit. Even small leftovers (filter model fragments,\n",
890
+ "# baseline corpus tensors) push 24GB cards over the edge during MNR training,\n",
891
+ "# which materializes a (B, S, H) activation graph for 3 streams (q, p, n).\n",
892
+ "import gc as _gc\n",
893
+ "_gc.collect()\n",
894
+ "if torch.cuda.is_available():\n",
895
+ " torch.cuda.empty_cache()\n",
896
+ " torch.cuda.synchronize()\n",
897
+ " torch.cuda.empty_cache()\n",
898
+ " try: torch.cuda.reset_peak_memory_stats()\n",
899
+ " except Exception: pass\n",
900
+ " free_gb, total_gb = (x/1e9 for x in torch.cuda.mem_get_info())\n",
901
+ " print(f'GPU before mini-fit: free={free_gb:.2f} GB / {total_gb:.2f} GB')\n",
902
+ "\n",
903
+ "_random.seed(11)\n",
904
+ "mini_examples = _random.sample(examples, min(2000, len(examples)))\n",
905
+ "mini_loader = DataLoader(mini_examples, batch_size=CFG['BATCH_SIZE'], shuffle=True, drop_last=True)\n",
906
+ "\n",
907
+ "loss_fn = losses.MultipleNegativesRankingLoss(model)\n",
908
+ "\n",
909
+ "import logging; logging.getLogger('sentence_transformers').setLevel(logging.WARNING)\n",
910
+ "\n",
911
+ "print(f'Mini-train (50 steps, batch={CFG[\"BATCH_SIZE\"]})...')\n",
912
+ "model.fit(\n",
913
+ " train_objectives=[(mini_loader, loss_fn)],\n",
914
+ " epochs=1,\n",
915
+ " steps_per_epoch=50,\n",
916
+ " warmup_steps=5,\n",
917
+ " optimizer_params={'lr': CFG['LEARNING_RATE']},\n",
918
+ " use_amp=True, # critical: cuts activation memory ~2x\n",
919
+ " show_progress_bar=True,\n",
920
+ ")\n",
921
+ "# Re-check the same triple β€” margin should INCREASE\n",
922
+ "_emb = model.encode([_q, _p, _n], convert_to_tensor=True, normalize_embeddings=True)\n",
923
+ "cos_qp_after = (_emb[0] @ _emb[1].T).item()\n",
924
+ "cos_qn_after = (_emb[0] @ _emb[2].T).item()\n",
925
+ "print(f'\\n After 50 steps: cos(Q,P)={cos_qp_after:.4f} cos(Q,N)={cos_qn_after:.4f} margin={cos_qp_after - cos_qn_after:+.4f}')\n",
926
+ "print(f' Ξ”margin = {(cos_qp_after - cos_qn_after) - (cos_qp - cos_qn):+.4f} (expect positive)')\n",
927
+ "\n",
928
+ "# Cleanup post-mini-fit before full training\n",
929
+ "del mini_loader, mini_examples, _emb\n",
930
+ "_gc.collect()\n",
931
+ "if torch.cuda.is_available():\n",
932
+ " torch.cuda.empty_cache()\n",
933
+ "print('SMOKE 7 PASS β€” model is learning the contrastive objective')"
934
+ ]
935
+ },
936
+ {
937
+ "cell_type": "markdown",
938
+ "id": "af3e4ec7",
939
+ "metadata": {},
940
+ "source": [
941
+ "## Cell 17.5 β€” Custom batch sampler: unique qid per batch (MNR hygiene)\n",
942
+ "\n",
943
+ "MNR loss treats every other in-batch positive as a negative for the current anchor. If two examples in the same batch share a `qid` (different gold cits for the same query), one example's positive becomes the other's negative β€” punishing the model for correct retrieval.\n",
944
+ "\n",
945
+ "Fix: greedy batch sampler that drops the second occurrence of any qid within a batch. Cleaner loss signal at the cost of slightly fewer training steps."
946
+ ]
947
+ },
948
+ {
949
+ "cell_type": "code",
950
+ "execution_count": null,
951
+ "id": "cf930ce7",
952
+ "metadata": {},
953
+ "outputs": [],
954
+ "source": [
955
+ "class NoDupQidBatchSampler(torch.utils.data.Sampler):\n",
956
+ " \"\"\"Yield batches of indices such that no two examples share the same qid.\n",
957
+ " Uses example_qids tracked alongside `examples` in Cell 12.\"\"\"\n",
958
+ " def __init__(self, qids, batch_size, drop_last=True, seed=0):\n",
959
+ " self.qids = list(qids)\n",
960
+ " self.batch_size = batch_size\n",
961
+ " self.drop_last = drop_last\n",
962
+ " self.seed = seed\n",
963
+ " self.epoch = 0\n",
964
+ " def __iter__(self):\n",
965
+ " rng = np.random.RandomState(self.seed + self.epoch)\n",
966
+ " order = rng.permutation(len(self.qids))\n",
967
+ " used = set()\n",
968
+ " batch = []\n",
969
+ " for i in order:\n",
970
+ " qid = self.qids[i]\n",
971
+ " if qid in used:\n",
972
+ " continue\n",
973
+ " batch.append(int(i)); used.add(qid)\n",
974
+ " if len(batch) == self.batch_size:\n",
975
+ " yield batch\n",
976
+ " batch = []; used = set()\n",
977
+ " if batch and not self.drop_last:\n",
978
+ " yield batch\n",
979
+ " self.epoch += 1\n",
980
+ " def __len__(self):\n",
981
+ " return len(self.qids) // self.batch_size\n",
982
+ "\n",
983
+ "# example_qids was already built in parallel with `examples` in Cell 12.\n",
984
+ "# No rebuild needed β€” that wastes memory and creates a stale list.\n",
985
+ "assert len(examples) == len(example_qids), (\n",
986
+ " f'examples ({len(examples)}) and example_qids ({len(example_qids)}) drifted β€” rerun Cell 12.'\n",
987
+ ")\n",
988
+ "print(f'Sampler ready. {len(examples):,} examples, {len(set(example_qids)):,} unique qids.')"
989
+ ]
990
+ },
991
+ {
992
+ "cell_type": "markdown",
993
+ "id": "9cabf1a8",
994
+ "metadata": {},
995
+ "source": [
996
+ "## Cell 18 β€” Full training run"
997
+ ]
998
+ },
999
+ {
1000
+ "cell_type": "code",
1001
+ "execution_count": null,
1002
+ "id": "ddb6a4b6",
1003
+ "metadata": {},
1004
+ "outputs": [],
1005
+ "source": [
1006
+ "# Aggressive cleanup before full training\n",
1007
+ "gc.collect()\n",
1008
+ "if torch.cuda.is_available():\n",
1009
+ " torch.cuda.empty_cache()\n",
1010
+ " torch.cuda.synchronize()\n",
1011
+ " torch.cuda.empty_cache()\n",
1012
+ " try: torch.cuda.reset_peak_memory_stats()\n",
1013
+ " except Exception: pass\n",
1014
+ " free_gb, total_gb = (x/1e9 for x in torch.cuda.mem_get_info())\n",
1015
+ " print(f'GPU before full fit: free={free_gb:.2f} GB / {total_gb:.2f} GB')\n",
1016
+ "\n",
1017
+ "if CFG['BATCH_NO_DUP_QID']:\n",
1018
+ " sampler = NoDupQidBatchSampler(example_qids, batch_size=CFG['BATCH_SIZE'], drop_last=True, seed=CFG['SEED'])\n",
1019
+ " train_loader = DataLoader(examples, batch_sampler=sampler, collate_fn=model.smart_batching_collate)\n",
1020
+ " print(f'Using NoDupQidBatchSampler β€” {len(sampler)} batches/epoch')\n",
1021
+ "else:\n",
1022
+ " _random.shuffle(examples)\n",
1023
+ " train_loader = DataLoader(examples, batch_size=CFG['BATCH_SIZE'], shuffle=True, drop_last=True)\n",
1024
+ "\n",
1025
+ "steps_per_epoch = len(train_loader)\n",
1026
+ "warmup = int(CFG['WARMUP_RATIO'] * steps_per_epoch * CFG['EPOCHS'])\n",
1027
+ "print(f'Full training: {CFG[\"EPOCHS\"]} epochs Γ— {steps_per_epoch} steps (warmup {warmup})')\n",
1028
+ "\n",
1029
+ "ckpt_dir = OUT / 'checkpoint_e5legal_v1'\n",
1030
+ "# NOTE: we DO NOT pass evaluator=... to fit() because:\n",
1031
+ "# 1. ST 2.7's IR evaluator returns 0.0000 (silently broken)\n",
1032
+ "# 2. it re-encodes 175k corpus on GPU each eval step β†’ OOM\n",
1033
+ "# We do a single manual eval after training in Cell 19.\n",
1034
+ "model.fit(\n",
1035
+ " train_objectives=[(train_loader, loss_fn)],\n",
1036
+ " epochs=CFG['EPOCHS'],\n",
1037
+ " output_path=str(ckpt_dir),\n",
1038
+ " save_best_model=False, # no evaluator, so save final state at end\n",
1039
+ " warmup_steps=warmup,\n",
1040
+ " optimizer_params={'lr': CFG['LEARNING_RATE']},\n",
1041
+ " use_amp=True,\n",
1042
+ " show_progress_bar=True,\n",
1043
+ ")\n",
1044
+ "# Save explicitly β€” fit() with save_best_model=False doesn't always save a final checkpoint\n",
1045
+ "model.save(str(ckpt_dir))\n",
1046
+ "print(f'\\nFine-tuned model saved to: {ckpt_dir}')"
1047
+ ]
1048
+ },
1049
+ {
1050
+ "cell_type": "markdown",
1051
+ "id": "aa377513",
1052
+ "metadata": {},
1053
+ "source": [
1054
+ "## Cell 19 β€” SMOKE TEST 8: Final val Recall@40, compare to baseline"
1055
+ ]
1056
+ },
1057
+ {
1058
+ "cell_type": "code",
1059
+ "execution_count": null,
1060
+ "id": "90530a21",
1061
+ "metadata": {},
1062
+ "outputs": [],
1063
+ "source": [
1064
+ "# Move trained model off GPU before loading the saved checkpoint to free VRAM\n",
1065
+ "print('Releasing training model from GPU...')\n",
1066
+ "try:\n",
1067
+ " model.to('cpu')\n",
1068
+ "except Exception: pass\n",
1069
+ "gc.collect()\n",
1070
+ "if torch.cuda.is_available():\n",
1071
+ " torch.cuda.empty_cache()\n",
1072
+ " torch.cuda.synchronize()\n",
1073
+ " torch.cuda.empty_cache()\n",
1074
+ " free_gb, total_gb = (x/1e9 for x in torch.cuda.mem_get_info())\n",
1075
+ " print(f'GPU after release: free={free_gb:.2f} GB / {total_gb:.2f} GB')\n",
1076
+ "\n",
1077
+ "# Reload from disk for a clean state\n",
1078
+ "print(f'Loading fine-tuned checkpoint from {ckpt_dir}...')\n",
1079
+ "best_model = SentenceTransformer(str(ckpt_dir), device='cuda')\n",
1080
+ "\n",
1081
+ "# Manual eval\n",
1082
+ "finetuned_recall40, mean_r_ft, med_r_ft = eval_recall_and_rank(\n",
1083
+ " best_model, k=CFG['VAL_RECALL_K'], label='fine-tuned'\n",
1084
+ ")\n",
1085
+ "\n",
1086
+ "print(f'\\nVal Recall@{CFG[\"VAL_RECALL_K\"]}:')\n",
1087
+ "print(f' baseline = {baseline_recall40:.4f}')\n",
1088
+ "print(f' fine-tuned = {finetuned_recall40:.4f}')\n",
1089
+ "delta = finetuned_recall40 - baseline_recall40\n",
1090
+ "print(f' Ξ” = {delta:+.4f}')\n",
1091
+ "if delta < 0.01:\n",
1092
+ " print(' WARNING: improvement under 1pp on val Recall@40. Sanity-check hard negatives + epochs.')\n",
1093
+ "else:\n",
1094
+ " print(' PASS β€” meaningful upstream improvement.')\n",
1095
+ "print('\\nSMOKE 8 PASS')"
1096
+ ]
1097
+ },
1098
+ {
1099
+ "cell_type": "markdown",
1100
+ "id": "86bc4061",
1101
+ "metadata": {},
1102
+ "source": [
1103
+ "## Cell 19.5 β€” GATE B: Avg gold rank improvement\n",
1104
+ "\n",
1105
+ "Second eval gate alongside Ξ”Recall@40. We require the **mean rank of gold cits** in the corpus retrieval to drop after fine-tuning. If Recall@40 improves but mean rank doesn't, the model just shuffled within the top-40 without lifting harder gold up the list.\n",
1106
+ "\n",
1107
+ "**Both gates must pass before re-encoding.**"
1108
+ ]
1109
+ },
1110
+ {
1111
+ "cell_type": "code",
1112
+ "execution_count": null,
1113
+ "id": "c994bddd",
1114
+ "metadata": {},
1115
+ "outputs": [],
1116
+ "source": [
1117
+ "# Gate B uses mean gold rank β€” both baseline and fine-tuned values were ALREADY\n",
1118
+ "# computed by eval_recall_and_rank() in Cells 16 and 19. No need to reload the\n",
1119
+ "# baseline encoder or re-encode the corpus.\n",
1120
+ "\n",
1121
+ "print(f'\\n Baseline: mean_rank={mean_r_base:>8.0f} median_rank={med_r_base:>6.0f}')\n",
1122
+ "print(f' Fine-tuned: mean_rank={mean_r_ft:>8.0f} median_rank={med_r_ft:>6.0f}')\n",
1123
+ "print(f' Ξ”mean_rank: {mean_r_base - mean_r_ft:+.0f} (positive = improvement)')\n",
1124
+ "\n",
1125
+ "gate_a_pass = (delta is not None and delta >= 0.01)\n",
1126
+ "gate_b_pass = (mean_r_ft <= mean_r_base)\n",
1127
+ "\n",
1128
+ "print(f'\\n Gate A (Ξ”Recall@{CFG[\"VAL_RECALL_K\"]} β‰₯ +0.01): {\"PASS\" if gate_a_pass else \"FAIL\"} ({delta:+.4f})')\n",
1129
+ "print(f' Gate B (mean gold rank improves): {\"PASS\" if gate_b_pass else \"FAIL\"}')\n",
1130
+ "\n",
1131
+ "if gate_a_pass and gate_b_pass:\n",
1132
+ " print('\\n>>> BOTH GATES PASS β€” proceed to corpus re-encoding cells.')\n",
1133
+ " PROCEED_TO_REENCODE = True\n",
1134
+ "else:\n",
1135
+ " print('\\n>>> STOP β€” do NOT re-encode laws_de or queries yet.')\n",
1136
+ " print(' Iterate hyperparameters before continuing:')\n",
1137
+ " print(' - try LEARNING_RATE 5e-5 or 1e-5 instead of 1.5e-5')\n",
1138
+ " print(' - increase EPOCHS to 3')\n",
1139
+ " print(' - lower FALSE_NEG_SIM_RATIO to 0.92 (more aggressive filter)')\n",
1140
+ " print(' - increase N_HARD_NEGS_PER_POS to 6')\n",
1141
+ " PROCEED_TO_REENCODE = False"
1142
+ ]
1143
+ },
1144
+ {
1145
+ "cell_type": "markdown",
1146
+ "metadata": {},
1147
+ "source": [
1148
+ "## Cell 20 β€” Re-encode laws_de.csv with fine-tuned model"
1149
+ ]
1150
+ },
1151
+ {
1152
+ "cell_type": "code",
1153
+ "execution_count": null,
1154
+ "metadata": {},
1155
+ "outputs": [],
1156
+ "source": [
1157
+ "passages = [P_PFX + str(t) for t in laws['text'].astype(str).tolist()]\n",
1158
+ "print(f'Re-encoding {len(passages):,} law passages...')\n",
1159
+ "t0 = time.time()\n",
1160
+ "laws_emb = best_model.encode(\n",
1161
+ " passages,\n",
1162
+ " batch_size=CFG['ENCODE_BATCH'],\n",
1163
+ " show_progress_bar=True,\n",
1164
+ " convert_to_numpy=True,\n",
1165
+ " normalize_embeddings=True,\n",
1166
+ " precision='float32',\n",
1167
+ ")\n",
1168
+ "print(f' done in {time.time()-t0:.1f}s shape={laws_emb.shape}')\n",
1169
+ "np.save(OUT / 'laws_emb_v1.npy', laws_emb.astype(np.float16))\n",
1170
+ "print(f' saved {OUT}/laws_emb_v1.npy ({laws_emb.nbytes/2/1e6:.1f} MB as fp16)')"
1171
+ ]
1172
+ },
1173
+ {
1174
+ "cell_type": "markdown",
1175
+ "metadata": {},
1176
+ "source": [
1177
+ "## Cell 21 β€” Re-encode train/val/test queries"
1178
+ ]
1179
+ },
1180
+ {
1181
+ "cell_type": "code",
1182
+ "execution_count": null,
1183
+ "metadata": {},
1184
+ "outputs": [],
1185
+ "source": [
1186
+ "def enc(texts, prefix=Q_PFX):\n",
1187
+ " arr = best_model.encode(\n",
1188
+ " [prefix + str(t) for t in texts],\n",
1189
+ " batch_size=CFG['ENCODE_BATCH'], show_progress_bar=True,\n",
1190
+ " convert_to_numpy=True, normalize_embeddings=True,\n",
1191
+ " ).astype(np.float32)\n",
1192
+ " return arr\n",
1193
+ "\n",
1194
+ "q_train = enc(train['query'].tolist())\n",
1195
+ "q_val = enc(val['query'].tolist())\n",
1196
+ "q_test = enc(test['query'].tolist())\n",
1197
+ "print(f'shapes: train={q_train.shape} val={q_val.shape} test={q_test.shape}')\n",
1198
+ "\n",
1199
+ "np.savez_compressed(OUT / 'queries_v1.npz',\n",
1200
+ " train=q_train.astype(np.float16),\n",
1201
+ " val=q_val.astype(np.float16),\n",
1202
+ " test=q_test.astype(np.float16))\n",
1203
+ "print(f'saved {OUT}/queries_v1.npz')"
1204
+ ]
1205
+ },
1206
+ {
1207
+ "cell_type": "markdown",
1208
+ "metadata": {},
1209
+ "source": [
1210
+ "## Cell 22 β€” Quick val F1 sanity (top-K cosine retrieval, K=18 to mimic v51 K)"
1211
+ ]
1212
+ },
1213
+ {
1214
+ "cell_type": "code",
1215
+ "execution_count": null,
1216
+ "metadata": {},
1217
+ "outputs": [],
1218
+ "source": [
1219
+ "K_PRED = 18\n",
1220
+ "rows = []\n",
1221
+ "for i, r in val.iterrows():\n",
1222
+ " qe = q_val[i:i+1]\n",
1223
+ " sims = (qe @ laws_emb.T)[0]\n",
1224
+ " top = np.argsort(-sims)[:K_PRED]\n",
1225
+ " pred = set(laws_cits_arr[j] for j in top)\n",
1226
+ " gold = set(c.strip() for c in str(r['gold_citations']).split(';') if c.strip())\n",
1227
+ " gold_in_laws = gold & laws_cit_set\n",
1228
+ " if not gold_in_laws:\n",
1229
+ " rows.append({'qid': r['query_id'], 'tp': 0, 'precision': 0, 'recall': 0, 'f1': 0, 'gold_in_laws': 0})\n",
1230
+ " continue\n",
1231
+ " tp = len(pred & gold_in_laws)\n",
1232
+ " prec = tp / max(1, len(pred))\n",
1233
+ " rec = tp / len(gold_in_laws)\n",
1234
+ " f1 = 2*prec*rec / max(1e-9, prec+rec)\n",
1235
+ " rows.append({'qid': r['query_id'], 'tp': tp, 'precision': prec, 'recall': rec, 'f1': f1, 'gold_in_laws': len(gold_in_laws)})\n",
1236
+ "\n",
1237
+ "ev = pd.DataFrame(rows)\n",
1238
+ "print(ev)\n",
1239
+ "print(f'\\nVal mean macro-F1 (laws-only retrieval @ K={K_PRED}): {ev[\"f1\"].mean():.4f}')\n",
1240
+ "ev.to_csv(OUT / 'val_baseline_vs_finetuned.csv', index=False)"
1241
+ ]
1242
+ },
1243
+ {
1244
+ "cell_type": "markdown",
1245
+ "metadata": {},
1246
+ "source": [
1247
+ "## Cell 23 β€” Push checkpoint + embeddings to Hugging Face (optional)"
1248
+ ]
1249
+ },
1250
+ {
1251
+ "cell_type": "code",
1252
+ "execution_count": null,
1253
+ "metadata": {},
1254
+ "outputs": [],
1255
+ "source": [
1256
+ "# Uncomment + set token to push:\n",
1257
+ "# from huggingface_hub import HfApi, login\n",
1258
+ "# login(token='hf_xxx') # or env var HF_TOKEN\n",
1259
+ "# api = HfApi()\n",
1260
+ "# repo = 'Dharun72/swiss-legal-checkpoints'\n",
1261
+ "# api.create_repo(repo, repo_type='model', exist_ok=True)\n",
1262
+ "# api.upload_folder(folder_path=str(ckpt_dir), repo_id=repo, repo_type='model', path_in_repo='e5legal_v1')\n",
1263
+ "# api.upload_file(path_or_fileobj=str(OUT / 'laws_emb_v1.npy'), path_in_repo='laws_emb_v1.npy', repo_id=repo, repo_type='model')\n",
1264
+ "# api.upload_file(path_or_fileobj=str(OUT / 'queries_v1.npz'), path_in_repo='queries_v1.npz', repo_id=repo, repo_type='model')\n",
1265
+ "# print('uploaded')\n",
1266
+ "print('skipped β€” uncomment when ready to push')"
1267
+ ]
1268
+ },
1269
+ {
1270
+ "cell_type": "markdown",
1271
+ "metadata": {},
1272
+ "source": [
1273
+ "---\n",
1274
+ "## What to look at while it runs\n",
1275
+ "\n",
1276
+ "1. **SMOKE 1** β€” GPU mem >= 16GB and loads tiny e5. If fails, you're CPU-only β€” abort.\n",
1277
+ "2. **SMOKE 2** β€” train gold coverage in laws_de should be β‰₯80%. If lower, your CSV is corrupted or laws_de is stale.\n",
1278
+ "3. **SMOKE 3** β€” pair count >= 1000. If much lower, citation strings don't match laws_de format β€” likely a normalization bug.\n",
1279
+ "4. **SMOKE 4** β€” printed hard negs look topically similar to query but distinct from gold. If they look random, BM25 isn't being scored on the right tokens.\n",
1280
+ "5. **SMOKE 5** β€” baseline cos(Q,Pos) should be > cos(Q,Neg). If reversed, prefix or pooling is broken.\n",
1281
+ "6. **SMOKE 6** β€” record baseline Recall@40. This is what fine-tune must beat.\n",
1282
+ "7. **SMOKE 7** β€” after 50 steps, margin (Q,Pos)-(Q,Neg) MUST increase. If not, lr too low or loss broken.\n",
1283
+ "8. **SMOKE 8** β€” Ξ” Recall@40 β‰₯ +0.01 is a green light. < +0.005 means the contrastive signal is weak; iterate hyperparameters before re-encoding the corpus.\n",
1284
+ "9. **GATE B** β€” mean gold rank must improve. If Recall@40 lifts but rank doesn't, the model is shuffling within top-40 instead of pulling harder gold up. Iterate before re-encoding.\n",
1285
+ "\n",
1286
+ "**Gate B (avg gold rank)** must ALSO pass alongside SMOKE 8.\n",
1287
+ "\n",
1288
+ "**If both gates pass**, the new `laws_emb_v1.npy` and `queries_v1.npz` go back into the Kaggle notebook to replace the existing precomputed embeddings β€” that's where the LB lift will show up."
1289
+ ]
1290
+ }
1291
+ ],
1292
+ "metadata": {
1293
+ "kernelspec": {
1294
+ "display_name": "Python 3",
1295
+ "language": "python",
1296
+ "name": "python3"
1297
+ },
1298
+ "language_info": {
1299
+ "name": "python",
1300
+ "version": "3.10"
1301
+ }
1302
+ },
1303
+ "nbformat": 4,
1304
+ "nbformat_minor": 5
1305
+ }