File size: 13,343 Bytes
4700286
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
#!/usr/bin/env python3
"""
scale_experiment.py β€” Scale comparison: KAIZEN 42M vs GPT-2 117M.

Research question: does semantic structure recoverable by a linear head
from frozen LM hidden states improve with model scale?

Protocol (identical to Phase 7.0 / semantic_probe.py):
  - Same 50 paraphrase pairs from probe_paraphrase.json
  - Same 70/30 task-level split (35 train, 15 held-out)
  - Same LinearHead architecture (d_model→128, InfoNCE)
  - Same recall@1 metric on held-out tasks

Output:
  - Raw recall@1 (no head) at each scale = lower bound
  - Recall@1 learning curve across epochs [0,10,25,50,100,150,200,300]
  - Final recall@1 = publishable single-number comparison

KAIZEN 42M: embed_task via canonical prompt [BOS,USER]+q+[ASST], mean-pool, 512-dim
GPT-2 117M: plain question text tokenized with GPT-2 BPE, mean-pool last hidden, 768-dim
"""
import json, random, sys, time
import torch
import torch.nn as nn
import torch.nn.functional as F
from tokenizers import Tokenizer
from transformers import GPT2Model, GPT2Tokenizer
from huggingface_hub import hf_hub_download

from lora import KaizenWithLoRA
from eval_benchmark import BENCHMARK_TASKS, build_prompt_ids, BLOCK_SIZE, MAX_GEN
from online_learner import HF_TOKEN, HF_TOK_REPO

# ── Config ────────────────────────────────────────────────────────────────────
def _get_default_ckpt():
    from online_learner import HF_TOKEN
    from huggingface_hub import hf_hub_download
    return hf_hub_download('qoa/kaizen-42m', 'phase4_latest.pt', token=HF_TOKEN)

DEFAULT_CKPT = _get_default_ckpt()
PROBE_PATH  = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'probe_paraphrase.json')
SEED        = 42
TRAIN_FRAC  = 0.70
HEAD_DIM    = 128
HEAD_LR     = 1e-3
HEAD_TEMP   = 0.07
CHECKPOINTS = [0, 10, 25, 50, 100, 150, 200, 300]   # epochs at which to record recall@1


# ── LinearHead + InfoNCE ──────────────────────────────────────────────────────
class LinearHead(nn.Module):
    def __init__(self, d_in: int, d_out: int = HEAD_DIM):
        super().__init__()
        self.proj = nn.Linear(d_in, d_out, bias=True)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return F.normalize(self.proj(x), dim=-1)


def infonce_loss(a: torch.Tensor, b: torch.Tensor,
                 temp: float = HEAD_TEMP) -> torch.Tensor:
    """Bidirectional InfoNCE on (anchor, positive) pairs. a, b: [B, D]."""
    sim    = a @ b.T / temp               # [B, B]
    labels = torch.arange(sim.shape[0])
    return (F.cross_entropy(sim, labels) + F.cross_entropy(sim.T, labels)) / 2


# ── Metrics ───────────────────────────────────────────────────────────────────
def recall_at_1_head(head: LinearHead,
                     anc_embs: torch.Tensor,
                     pos_embs: torch.Tensor,
                     held_out_idx: list) -> float:
    """Recall@1 in g-space.

    For each held-out anchor i: query = head(anc_embs[i]).
    Gallery = head(pos_embs) over ALL 50 tasks (train + held-out).
    Correct if nearest gallery entry == i (the true paraphrase).
    """
    with torch.no_grad():
        g_pos = head(pos_embs)                       # [50, 128]
        g_anc = head(anc_embs[held_out_idx])         # [N_held, 128]

    correct = 0
    for k, i in enumerate(held_out_idx):
        dists   = ((g_pos - g_anc[k].unsqueeze(0)) ** 2).sum(dim=1)  # [50]
        nearest = int(dists.argmin().item())
        if nearest == i:
            correct += 1
    return correct / len(held_out_idx)


def recall_at_1_raw(anc_embs: torch.Tensor,
                    pos_embs: torch.Tensor,
                    held_out_idx: list) -> float:
    """Recall@1 in raw embedding space (L2-normalized, cosine-equivalent)."""
    with torch.no_grad():
        a_n = F.normalize(anc_embs, dim=-1)          # [50, d]
        p_n = F.normalize(pos_embs, dim=-1)          # [50, d]
    correct = 0
    for i in held_out_idx:
        dists   = ((p_n - a_n[i].unsqueeze(0)) ** 2).sum(dim=1)
        nearest = int(dists.argmin().item())
        if nearest == i:
            correct += 1
    return correct / len(held_out_idx)


# ── Training loop ─────────────────────────────────────────────────────────────
def train_with_checkpoints(d_in: int,
                           anc_train: torch.Tensor,
                           pos_train: torch.Tensor,
                           anc_all: torch.Tensor,
                           pos_all: torch.Tensor,
                           held_out_idx: list) -> dict:
    """Train LinearHead; return {epoch: recall@1} at each CHECKPOINT epoch."""
    head      = LinearHead(d_in=d_in)
    optimizer = torch.optim.Adam(head.parameters(), lr=HEAD_LR)
    N         = anc_train.shape[0]
    results   = {}

    if 0 in CHECKPOINTS:
        results[0] = recall_at_1_head(head, anc_all, pos_all, held_out_idx)

    max_ep = max(CHECKPOINTS)
    for ep in range(1, max_ep + 1):
        perm = torch.randperm(N)
        optimizer.zero_grad()
        loss = infonce_loss(head(anc_train[perm]), head(pos_train[perm]))
        loss.backward()
        optimizer.step()
        if ep in CHECKPOINTS:
            results[ep] = recall_at_1_head(head, anc_all, pos_all, held_out_idx)

    return results


# ── Embedding extractors ──────────────────────────────────────────────────────
def embed_kaizen(model: KaizenWithLoRA, tokenizer,
                 questions: list) -> torch.Tensor:
    """Canonical prompt embed_task for all questions. Returns [N, 512]."""
    vecs = []
    with torch.no_grad():
        for q in questions:
            pids = build_prompt_ids(tokenizer, q)[:BLOCK_SIZE - MAX_GEN]
            x    = torch.tensor([pids], dtype=torch.long)
            vecs.append(model.embed_task(x, adapter=None))   # [512]
    return torch.stack(vecs)


def embed_gpt2(model: GPT2Model, tokenizer: GPT2Tokenizer,
               questions: list) -> torch.Tensor:
    """Mean-pool GPT-2 last hidden state for all questions. Returns [N, 768]."""
    vecs = []
    with torch.no_grad():
        for q in questions:
            inputs = tokenizer(q, return_tensors='pt',
                               truncation=True, max_length=512)
            out    = model(**inputs)
            # last_hidden_state: [1, seq_len, 768] β†’ mean over seq_len
            vecs.append(out.last_hidden_state[0].mean(dim=0))   # [768]
    return torch.stack(vecs)


# ── Main ──────────────────────────────────────────────────────────────────────
def main():
    t0  = time.time()
    rng = random.Random(SEED)

    print('Scale experiment: KAIZEN 42M vs GPT-2 117M')
    print('Metric: recall@1 on held-out paraphrase pairs')
    print(f'Checkpoints: {CHECKPOINTS}')
    print('=' * 64)

    # ── Probe data ───────────────────────────────────────────────────────────
    with open(PROBE_PATH) as f:
        paraphrases = json.load(f)   # list of [para_q, answer, category]

    orig_qs = [t[0] for t in BENCHMARK_TASKS]   # 50 original questions
    para_qs = [p[0] for p in paraphrases]        # 50 paraphrase questions

    # 70/30 task-level split β€” same seed as Phase 7.0
    all_idx = list(range(50))
    rng.shuffle(all_idx)
    n_train      = int(TRAIN_FRAC * 50)   # 35
    train_idx    = all_idx[:n_train]
    held_out_idx = all_idx[n_train:]      # 15
    print(f'Train tasks: {n_train}  |  Held-out tasks: {len(held_out_idx)}')

    # ── KAIZEN 42M embeddings ─────────────────────────────────────────────────
    print('\n[1/4] Loading KAIZEN 42M and embedding probe questions...')
    tok_file = hf_hub_download(HF_TOK_REPO, 'tokenizer.json',
                                token=HF_TOKEN, cache_dir=None)
    kz_tok   = Tokenizer.from_file(tok_file)
    kz_model = KaizenWithLoRA()
    kz_model.load_base(DEFAULT_CKPT)
    kz_model.eval()
    kz_params = sum(p.numel() for p in kz_model.parameters())
    print(f'  KAIZEN: {kz_params/1e6:.1f}M params')

    kz_anc = embed_kaizen(kz_model, kz_tok, orig_qs)   # [50, 512]
    kz_pos = embed_kaizen(kz_model, kz_tok, para_qs)   # [50, 512]
    del kz_model   # free model weights; tensors remain valid
    print(f'  Embeddings: {list(kz_anc.shape)}  (elapsed {time.time()-t0:.0f}s)')

    # ── GPT-2 117M embeddings ─────────────────────────────────────────────────
    print('\n[2/4] Loading GPT-2 117M and embedding probe questions...')
    gpt2_tok   = GPT2Tokenizer.from_pretrained('gpt2',
                                                cache_dir='/tmp/gpt2_cache')
    gpt2_model = GPT2Model.from_pretrained('gpt2',
                                            cache_dir='/tmp/gpt2_cache')
    gpt2_model.eval()
    gpt2_params = sum(p.numel() for p in gpt2_model.parameters())
    print(f'  GPT-2: {gpt2_params/1e6:.1f}M params')

    g2_anc = embed_gpt2(gpt2_model, gpt2_tok, orig_qs)   # [50, 768]
    g2_pos = embed_gpt2(gpt2_model, gpt2_tok, para_qs)   # [50, 768]
    del gpt2_model   # free model weights
    print(f'  Embeddings: {list(g2_anc.shape)}  (elapsed {time.time()-t0:.0f}s)')

    # ── Raw recall@1 (no head) ────────────────────────────────────────────────
    kz_raw = recall_at_1_raw(kz_anc, kz_pos, held_out_idx)
    g2_raw = recall_at_1_raw(g2_anc, g2_pos, held_out_idx)
    print(f'\nRaw recall@1 (frozen embeddings, no head, cosine NN):')
    print(f'  KAIZEN 42M  : {kz_raw:.4f}')
    print(f'  GPT-2  117M : {g2_raw:.4f}')
    print(f'  Ξ” (117Mβˆ’42M): {g2_raw - kz_raw:+.4f}')

    # ── Head training: build train tensors ────────────────────────────────────
    kz_anc_tr = kz_anc[train_idx]   # [35, 512]
    kz_pos_tr = kz_pos[train_idx]   # [35, 512]
    g2_anc_tr = g2_anc[train_idx]   # [35, 768]
    g2_pos_tr = g2_pos[train_idx]   # [35, 768]

    # ── Train KAIZEN head ─────────────────────────────────────────────────────
    print(f'\n[3/4] Training LinearHead for KAIZEN 42M (512β†’{HEAD_DIM}, InfoNCE)...')
    kz_curve = train_with_checkpoints(
        d_in=512,
        anc_train=kz_anc_tr, pos_train=kz_pos_tr,
        anc_all=kz_anc, pos_all=kz_pos,
        held_out_idx=held_out_idx,
    )
    print(f'  Done (elapsed {time.time()-t0:.0f}s)')

    # ── Train GPT-2 head ──────────────────────────────────────────────────────
    print(f'[4/4] Training LinearHead for GPT-2 117M (768β†’{HEAD_DIM}, InfoNCE)...')
    g2_curve = train_with_checkpoints(
        d_in=768,
        anc_train=g2_anc_tr, pos_train=g2_pos_tr,
        anc_all=g2_anc, pos_all=g2_pos,
        held_out_idx=held_out_idx,
    )
    print(f'  Done (elapsed {time.time()-t0:.0f}s)')

    # ── Results table ─────────────────────────────────────────────────────────
    print()
    print('=' * 64)
    print('Recall@1 on held-out paraphrase pairs (linear head, InfoNCE, 35 train)')
    print(f'{"Epoch":>8s}  {"KAIZEN 42M":>12s}  {"GPT-2 117M":>12s}  {"Ξ”(117M-42M)":>12s}')
    print('-' * 52)
    print(f'{"raw":>8s}  {kz_raw:12.4f}  {g2_raw:12.4f}  {g2_raw-kz_raw:+12.4f}')
    for ep in CHECKPOINTS:
        kz_r = kz_curve.get(ep, float('nan'))
        g2_r = g2_curve.get(ep, float('nan'))
        delta = g2_r - kz_r if (kz_r == kz_r and g2_r == g2_r) else float('nan')
        print(f'{ep:8d}  {kz_r:12.4f}  {g2_r:12.4f}  {delta:+12.4f}')

    kz_final = kz_curve.get(max(CHECKPOINTS), 0.0)
    g2_final = g2_curve.get(max(CHECKPOINTS), 0.0)

    print()
    print(f'Summary (epoch {max(CHECKPOINTS)}):')
    print(f'  KAIZEN 42M  raw={kz_raw:.4f} β†’ head={kz_final:.4f}  '
          f'(gain={kz_final-kz_raw:+.4f})')
    print(f'  GPT-2  117M raw={g2_raw:.4f} β†’ head={g2_final:.4f}  '
          f'(gain={g2_final-g2_raw:+.4f})')
    print(f'  Scale benefit at epoch 300: {g2_final - kz_final:+.4f} '
          f'({"117M > 42M" if g2_final > kz_final else "42M >= 117M"})')
    print(f'\nRuntime: {time.time()-t0:.0f}s')


if __name__ == '__main__':
    main()