diyclassics Claude Opus 4.6 commited on
Commit
73784ba
·
1 Parent(s): bbde973

test: add WSD case study reproduction (Bamman & Burns Table 2)

Browse files

Binary sense classification with 10-fold CV across 201 lemmas.
Validates accuracy against reference 75.4% with +/-2% tolerance.
Uses latin.sense.data from original repo. Trains a separate
BertForSequenceLabeling model per lemma for up to 100 epochs,
accumulates dev/test correct counts across all lemmas, and picks
the best dev epoch to report test accuracy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. tests/test_wsd.py +386 -0
tests/test_wsd.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Word sense disambiguation case study -- Bamman & Burns (2020) Table 2.
2
+
3
+ Reproduces the WSD experiment: for each polysemous lemma (201 total),
4
+ train a binary classifier (sense I vs sense II) on BERT embeddings with
5
+ 10-fold cross-validation. Pick the best epoch on dev, evaluate on test.
6
+
7
+ Reference results (from original logs):
8
+ OVERALL: epoch 9, accuracy 0.754, n=1070
9
+ """
10
+
11
+ import random
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ import pytest
16
+ import torch
17
+ from torch import nn
18
+ from torch.nn import CrossEntropyLoss
19
+ import torch.optim as optim
20
+ from transformers import AutoTokenizer, BertModel
21
+
22
+ random.seed(1)
23
+ torch.manual_seed(0)
24
+ np.random.seed(0)
25
+
26
+ MODEL_PATH = "/tmp/latin-bert-hub"
27
+ DATA_PATH = (
28
+ Path(__file__).parent.parent
29
+ / ".claude/reference/latin-bert/case_studies/wsd/data/latin.sense.data"
30
+ )
31
+
32
+ REF_ACCURACY = 0.754
33
+ TOLERANCE = 0.02 # WSD has more variance due to per-lemma training
34
+ BATCH_SIZE = 32
35
+ DROPOUT_RATE = 0.25
36
+ BERT_DIM = 768
37
+ MAX_EPOCHS = 100
38
+
39
+ # Special tokens that should not go through subword encoding
40
+ _SPECIAL_TOKENS = {"[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"}
41
+
42
+
43
+ def _word_to_subtokens(tokenizer, word):
44
+ """Get subtoken strings for a single word.
45
+
46
+ Special tokens ([CLS], [SEP], etc.) are returned as-is.
47
+ Regular words are lowercased and tokenized through the subword pipeline,
48
+ matching the original LatinTokenizer.tokenize() behavior.
49
+ """
50
+ if word in _SPECIAL_TOKENS:
51
+ return [word]
52
+ return tokenizer.tokenize(word.lower())
53
+
54
+
55
+ class BertForSequenceLabeling(nn.Module):
56
+ """BERT + linear classifier for sequence labeling (binary WSD)."""
57
+
58
+ def __init__(self, tokenizer, bert_model, freeze_bert=False,
59
+ num_labels=2):
60
+ super().__init__()
61
+ self.tokenizer = tokenizer
62
+ self.num_labels = num_labels
63
+ self.bert = bert_model
64
+ self.bert.eval()
65
+ if freeze_bert:
66
+ for param in self.bert.parameters():
67
+ param.requires_grad = False
68
+ self.dropout = nn.Dropout(DROPOUT_RATE)
69
+ self.classifier = nn.Linear(BERT_DIM, num_labels)
70
+
71
+ def forward(self, input_ids, attention_mask=None, transforms=None,
72
+ labels=None):
73
+ device = input_ids.device
74
+ if attention_mask is not None:
75
+ attention_mask = attention_mask.to(device)
76
+ if transforms is not None:
77
+ transforms = transforms.to(device)
78
+ if labels is not None:
79
+ labels = labels.to(device)
80
+
81
+ outputs = self.bert(input_ids, attention_mask=attention_mask)
82
+ sequence_output = outputs[0]
83
+ out = torch.matmul(transforms, sequence_output)
84
+ logits = self.classifier(out)
85
+
86
+ if labels is not None:
87
+ loss_fct = CrossEntropyLoss(ignore_index=-100)
88
+ return loss_fct(
89
+ logits.view(-1, self.num_labels), labels.view(-1)
90
+ )
91
+ return logits
92
+
93
+ def get_batches(self, sentences, max_batch):
94
+ """Tokenize and batch with subword-to-word transform matrices."""
95
+ all_data, all_masks, all_labels, all_transforms = [], [], [], []
96
+
97
+ for sentence in sentences:
98
+ tok_ids, input_mask, labels, transform = [], [], [], []
99
+ all_toks = []
100
+ n = 0
101
+ for word in sentence:
102
+ toks = _word_to_subtokens(self.tokenizer, word[0])
103
+ all_toks.append(toks)
104
+ n += len(toks)
105
+
106
+ cur = 0
107
+ for idx, word in enumerate(sentence):
108
+ toks = all_toks[idx]
109
+ ind = list(np.zeros(n))
110
+ for j in range(cur, cur + len(toks)):
111
+ ind[j] = 1.0 / len(toks)
112
+ cur += len(toks)
113
+ transform.append(ind)
114
+ tok_ids.extend(
115
+ self.tokenizer.convert_tokens_to_ids(toks)
116
+ )
117
+ input_mask.extend(np.ones(len(toks)))
118
+ labels.append(int(word[1]))
119
+
120
+ all_data.append(tok_ids)
121
+ all_masks.append(input_mask)
122
+ all_labels.append(labels)
123
+ all_transforms.append(transform)
124
+
125
+ lengths = np.array([len(l) for l in all_data])
126
+ ordering = np.argsort(lengths)
127
+
128
+ ordered_data = [None] * len(all_data)
129
+ ordered_masks = [None] * len(all_data)
130
+ ordered_labels = [None] * len(all_data)
131
+ ordered_transforms = [None] * len(all_data)
132
+
133
+ for i, ind in enumerate(ordering):
134
+ ordered_data[i] = all_data[ind]
135
+ ordered_masks[i] = all_masks[ind]
136
+ ordered_labels[i] = all_labels[ind]
137
+ ordered_transforms[i] = all_transforms[ind]
138
+
139
+ batched_data = []
140
+ batched_mask = []
141
+ batched_labels = []
142
+ batched_transforms = []
143
+
144
+ i = 0
145
+ current_batch = max_batch
146
+
147
+ while i < len(ordered_data):
148
+ bd = ordered_data[i:i + current_batch]
149
+ bm = ordered_masks[i:i + current_batch]
150
+ bl = ordered_labels[i:i + current_batch]
151
+ bt = ordered_transforms[i:i + current_batch]
152
+
153
+ ml = max(len(s) for s in bd)
154
+ mlabel = max(len(l) for l in bl)
155
+
156
+ for j in range(len(bd)):
157
+ for _k in range(len(bd[j]), ml):
158
+ bd[j].append(0)
159
+ bm[j].append(0)
160
+ for z in range(len(bt[j])):
161
+ bt[j][z].append(0)
162
+ for _k in range(len(bl[j]), mlabel):
163
+ bl[j].append(-100)
164
+ for _k in range(len(bt[j]), mlabel):
165
+ bt[j].append(np.zeros(ml))
166
+
167
+ batched_data.append(torch.LongTensor(bd))
168
+ batched_mask.append(torch.FloatTensor(bm))
169
+ batched_labels.append(torch.LongTensor(bl))
170
+ batched_transforms.append(torch.FloatTensor(bt))
171
+
172
+ i += current_batch
173
+ if ml > 100:
174
+ current_batch = 12
175
+ if ml > 200:
176
+ current_batch = 6
177
+
178
+ return (batched_data, batched_mask, batched_labels,
179
+ batched_transforms, ordering)
180
+
181
+
182
+ def _get_labs(before, target, after, label):
183
+ """Build a labeled sentence for WSD.
184
+
185
+ Only the target word gets a real label (0 or 1); all context words
186
+ get -100 (ignored by CrossEntropyLoss).
187
+ """
188
+ sent = []
189
+ for word in before.split(" "):
190
+ if word: # skip empty strings from split on empty/whitespace
191
+ sent.append((word, -100))
192
+ sent.append((target, label))
193
+ for word in after.split(" "):
194
+ if word:
195
+ sent.append((word, -100))
196
+ return sent
197
+
198
+
199
+ def _read_wsd_data(filename):
200
+ """Read WSD data file, return dict of lemma -> {0: [...], 1: [...]}."""
201
+ lemmas = {}
202
+ with open(filename) as f:
203
+ for line in f:
204
+ cols = line.split("\t")
205
+ lemma = cols[0]
206
+ label = cols[1]
207
+ before = cols[2]
208
+ target = cols[3]
209
+ after = cols[4].rstrip()
210
+ if lemma not in lemmas:
211
+ lemmas[lemma] = {0: [], 1: []}
212
+ if label == "I":
213
+ lemmas[lemma][0].append(
214
+ _get_labs(before, target, after, 0)
215
+ )
216
+ elif label == "II":
217
+ lemmas[lemma][1].append(
218
+ _get_labs(before, target, after, 1)
219
+ )
220
+ return lemmas
221
+
222
+
223
+ def _get_splits(data):
224
+ """10-fold cross-validation splits.
225
+
226
+ For each sense (0 and 1), examples are assigned to folds by index.
227
+ testFold = idx % 10, devFold = testFold - 1 (wrapping to 9).
228
+ """
229
+ trains, tests, devs = [], [], []
230
+ for _i in range(10):
231
+ trains.append([])
232
+ tests.append([])
233
+ devs.append([])
234
+
235
+ for sense_idx in [0, 1]:
236
+ for idx, sent in enumerate(data[sense_idx]):
237
+ test_fold = idx % 10
238
+ dev_fold = test_fold - 1 if test_fold > 0 else 9
239
+ for i in range(10):
240
+ if i == test_fold:
241
+ tests[i].append(sent)
242
+ elif i == dev_fold:
243
+ devs[i].append(sent)
244
+ else:
245
+ trains[i].append(sent)
246
+
247
+ for i in range(10):
248
+ random.shuffle(trains[i])
249
+ random.shuffle(tests[i])
250
+ random.shuffle(devs[i])
251
+
252
+ return trains, devs, tests
253
+
254
+
255
+ def _evaluate(model, batched_data, batched_mask, batched_labels,
256
+ batched_transforms, device):
257
+ """Evaluate model on batched data, return (correct, total).
258
+
259
+ Mirrors the original evaluate() method which returns (cor, tot),
260
+ with accumulation happening outside this function.
261
+ """
262
+ model.eval()
263
+ cor = 0
264
+ tot = 0
265
+ with torch.no_grad():
266
+ for b in range(len(batched_data)):
267
+ logits = model(
268
+ batched_data[b].to(device),
269
+ attention_mask=batched_mask[b],
270
+ transforms=batched_transforms[b],
271
+ )
272
+ logits = logits.cpu()
273
+ size = batched_labels[b].shape
274
+ logits = logits.view(-1, size[1], 2)
275
+ preds = np.argmax(logits.numpy(), axis=2)
276
+ for row in range(size[0]):
277
+ for col in range(size[1]):
278
+ if batched_labels[b][row][col] != -100:
279
+ if preds[row][col] == batched_labels[b][row][col]:
280
+ cor += 1
281
+ tot += 1
282
+ return cor, tot
283
+
284
+
285
+ @pytest.mark.slow
286
+ def test_wsd_accuracy():
287
+ """Reproduce WSD case study from Bamman & Burns (2020).
288
+
289
+ Trains a separate binary classifier per lemma (201 lemmas) with
290
+ 10-fold cross-validation. Uses fold 0 splits (train/dev/test).
291
+ Accumulates dev and test correct/total across all lemmas at each
292
+ epoch, then picks the best dev epoch and reports test accuracy.
293
+ """
294
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
295
+
296
+ tokenizer = AutoTokenizer.from_pretrained(
297
+ MODEL_PATH, trust_remote_code=True
298
+ )
299
+ data = _read_wsd_data(str(DATA_PATH))
300
+
301
+ dev_cors = [0.0] * MAX_EPOCHS
302
+ test_cors = [0.0] * MAX_EPOCHS
303
+ dev_n = [0.0] * MAX_EPOCHS
304
+ test_n = [0.0] * MAX_EPOCHS
305
+
306
+ for lemma_idx, lemma in enumerate(data):
307
+ print(f"\n[{lemma_idx + 1}/{len(data)}] {lemma}")
308
+
309
+ # Fresh model per lemma
310
+ bert_model = BertModel.from_pretrained(MODEL_PATH)
311
+ model = BertForSequenceLabeling(
312
+ tokenizer, bert_model, freeze_bert=False, num_labels=2
313
+ )
314
+ model.to(device)
315
+
316
+ trains, devs, tests = _get_splits(data[lemma])
317
+ train_data = trains[0]
318
+ dev_data = devs[0]
319
+ test_data = tests[0]
320
+
321
+ train_b, train_m, train_l, train_t, _ = model.get_batches(
322
+ train_data, BATCH_SIZE
323
+ )
324
+ dev_b, dev_m, dev_l, dev_t, _ = model.get_batches(
325
+ dev_data, BATCH_SIZE
326
+ )
327
+ test_b, test_m, test_l, test_t, _ = model.get_batches(
328
+ test_data, BATCH_SIZE
329
+ )
330
+
331
+ optimizer = optim.Adam(model.parameters(), lr=5e-5)
332
+
333
+ for epoch in range(MAX_EPOCHS):
334
+ model.train()
335
+ for b in range(len(train_b)):
336
+ loss = model(
337
+ train_b[b].to(device),
338
+ attention_mask=train_m[b],
339
+ transforms=train_t[b],
340
+ labels=train_l[b],
341
+ )
342
+ loss.backward()
343
+ optimizer.step()
344
+ model.zero_grad()
345
+
346
+ # Evaluate dev
347
+ c, t = _evaluate(
348
+ model, dev_b, dev_m, dev_l, dev_t, device
349
+ )
350
+ dev_cors[epoch] += c
351
+ dev_n[epoch] += t
352
+
353
+ # Evaluate test
354
+ c, t = _evaluate(
355
+ model, test_b, test_m, test_l, test_t, device
356
+ )
357
+ test_cors[epoch] += c
358
+ test_n[epoch] += t
359
+
360
+ # Print per-lemma dev accuracy summary
361
+ for epoch in range(MAX_EPOCHS):
362
+ if dev_n[epoch] > 0:
363
+ dev_acc = dev_cors[epoch] / dev_n[epoch]
364
+ print(
365
+ f" DEV: epoch={epoch} acc={dev_acc:.3f} "
366
+ f"lemma={lemma} n={dev_n[epoch]}"
367
+ )
368
+
369
+ # Find best dev epoch, report test accuracy at that epoch
370
+ best_epoch = max(
371
+ range(MAX_EPOCHS),
372
+ key=lambda i: dev_cors[i] / dev_n[i] if dev_n[i] > 0 else 0,
373
+ )
374
+ test_accuracy = test_cors[best_epoch] / test_n[best_epoch]
375
+
376
+ print(
377
+ f"\nOVERALL: epoch={best_epoch}, "
378
+ f"accuracy={test_accuracy:.3f}, "
379
+ f"n={test_n[best_epoch]}"
380
+ )
381
+ print(f"Reference: epoch=9, accuracy={REF_ACCURACY}, n=1070")
382
+
383
+ assert abs(test_accuracy - REF_ACCURACY) < TOLERANCE, (
384
+ f"WSD accuracy {test_accuracy:.3f} outside tolerance "
385
+ f"of {REF_ACCURACY} +/- {TOLERANCE}"
386
+ )