File size: 7,095 Bytes
78fd9bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a352d5
78fd9bf
25b7ba0
 
 
 
 
 
 
 
 
 
 
 
 
78fd9bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a352d5
78fd9bf
 
 
 
 
 
3a352d5
 
 
 
 
 
 
78fd9bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a352d5
ceb077a
 
78fd9bf
 
3a352d5
78fd9bf
 
 
3a352d5
 
 
 
 
 
 
 
 
 
 
 
78fd9bf
 
 
 
 
 
 
 
 
25b7ba0
78fd9bf
 
 
 
 
 
479242e
3a352d5
3602552
 
78fd9bf
3602552
 
3a352d5
78fd9bf
3a352d5
 
 
78fd9bf
3a352d5
78fd9bf
 
 
3a352d5
 
 
78fd9bf
 
 
 
3a352d5
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
from __future__ import annotations

import functools
from typing import Any

try:  # must precede torch/transformers imports: it patches CUDA allocation
    import spaces
except ImportError:  # pragma: no cover - local run
    spaces = None

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

BASELINE_REPO = "yava-code/Tessera-1B-Nano-Base"
CONCEPT_REPO = "yava-code/Tessera-1B-Nano"
EXAMPLE_PROMPTS = [
    "The most common metals are",
    "Photosynthesis is the process used by plants",
    "In the nineteenth century, transport changed because",
    "A simple recipe for bread needs flour, water and",
]
DEFAULT_MAX_NEW_TOKENS = 48
MAX_NEW_TOKENS_LIMIT = 160
DEFAULT_SEED = 7
INTRO_NOTE = (
    "Two copies of the same 360M-parameter language model, trained on the same billion"
    " tokens in the same order. The right one also carries a small second module: it"
    " compresses every four tokens into one concept code, guesses the *next* concept"
    " code, and feeds that guess back into the decoder while it writes.\n\n"
    "Type a prompt and watch both write. Then look at the third column: that is the"
    " same concept model with its concept feedback silenced. Something changes - and"
    " the loss line under the box shows how much the model minds. Now the strange"
    " part, measured on held-out data: replace the concept feedback with a concept"
    " predicted from a *different* sentence, and the model does not care. It reads the"
    " channel constantly - silencing it hurts - but whatever it reads there is not the"
    " sentence. What is it reading, then? That question is the study.\n\n"
    "*(Same seed and prompt give bit-identical generations on every click; change the"
    " seed to sample a different draw.)*"
)

MODELS: dict[str, tuple[AutoTokenizer, Any]] = {}


def load_models(*, gpu_dtype: bool = False) -> None:
    """Load both checkpoints on CPU; gpu_dtype selects bf16 for the ZeroGPU move."""
    if MODELS:
        return
    dtype = torch.bfloat16 if gpu_dtype else torch.float32
    for key, repo, trust in (
        ("base", BASELINE_REPO, False),
        ("concept", CONCEPT_REPO, True),
    ):
        tokenizer = AutoTokenizer.from_pretrained(repo)
        model = AutoModelForCausalLM.from_pretrained(
            repo,
            trust_remote_code=trust,
            dtype=dtype,
        )
        model.eval()
        MODELS[key] = (tokenizer, model)


def _device(model: Any) -> torch.device:
    return next(model.parameters()).device


def _generate(
    tokenizer: AutoTokenizer,
    model: Any,
    prompt: str,
    max_new_tokens: int,
    *,
    seed: int,
    zero_feedback: bool = False,
) -> str:
    inputs = tokenizer(prompt, return_tensors="pt").to(_device(model))
    if zero_feedback:
        # generate() rejects unknown kwargs, so route concept_mode through forward.
        model.forward = functools.partial(model.forward, concept_mode="zero")
    # transformers 4.x multinomial sampling draws from the global RNG (no generator
    # argument), so reproducibility needs a device-global seed reset per call.
    device = _device(model)
    if device.type == "cuda":
        torch.cuda.manual_seed(seed)
    else:
        torch.manual_seed(seed)
    try:
        with torch.no_grad():
            outputs = model.generate(
                **inputs,
                max_new_tokens=max_new_tokens,
                do_sample=True,
                temperature=0.8,
                top_p=0.95,
                pad_token_id=tokenizer.eos_token_id,
            )
    finally:
        if zero_feedback:
            del model.forward
    return tokenizer.decode(
        outputs[0][inputs["input_ids"].shape[1] :],
        skip_special_tokens=True,
    )


def _prompt_loss(
    tokenizer: AutoTokenizer,
    model: Any,
    prompt: str,
    *,
    zero_feedback: bool = False,
) -> float:
    inputs = tokenizer(prompt, return_tensors="pt").to(_device(model))
    with torch.no_grad():
        outputs = model(**inputs, concept_mode="zero") if zero_feedback else model(**inputs)
    logits = outputs.logits[:, :-1, :]
    targets = inputs["input_ids"][:, 1:]
    return float(
        torch.nn.functional.cross_entropy(
            logits.reshape(-1, logits.size(-1)),
            targets.reshape(-1),
            reduction="mean",
        )
    )


def _run(prompt: str, max_new_tokens: int, seed: int) -> tuple[str, str, str, str, str]:
    if set(MODELS) != {"base", "concept"}:
        raise RuntimeError("Models are still loading; retry in a minute.")
    prompt = prompt.strip() or EXAMPLE_PROMPTS[0]
    max_new_tokens = max(16, min(int(max_new_tokens), MAX_NEW_TOKENS_LIMIT))
    seed = max(0, min(int(seed), 2**31 - 1))
    base_tok, base_model = MODELS["base"]
    concept_tok, concept_model = MODELS["concept"]

    # Identical seeds across the arms make repeats bit-reproducible; per-arm offsets
    # only decorrelate the columns, they change nothing on a repeated click.
    base_text = _generate(base_tok, base_model, prompt, max_new_tokens, seed=seed)
    concept_text = _generate(concept_tok, concept_model, prompt, max_new_tokens, seed=seed + 1)
    zero_text = _generate(
        concept_tok,
        concept_model,
        prompt,
        max_new_tokens,
        seed=seed + 2,
        zero_feedback=True,
    )

    base_loss = _prompt_loss(base_tok, base_model, prompt)
    concept_loss = _prompt_loss(concept_tok, concept_model, prompt)
    zero_loss = _prompt_loss(concept_tok, concept_model, prompt, zero_feedback=True)
    delta = zero_loss - concept_loss

    base_note = f"teacher-forced NTP loss on the prompt: {base_loss:.3f}"
    concept_note = (
        f"teacher-forced NTP loss on the prompt: {concept_loss:.3f}; zeroing the concept"
        f" feedback costs {delta:+.3f} nats here - the model notices (held-out average below)"
    )
    return base_text, base_note, concept_text, zero_text, concept_note


if spaces is not None:  # ZeroGPU: models live on CPU, compute moves to a shared A100

    @spaces.GPU(duration=45)
    def _gpu_run(prompt: str, max_new_tokens: int, seed: int) -> tuple[str, str, str, str, str]:
        # Only the device move and the math run inside the GPU window: downloads and
        # CPU loading stay in the main process, or ZeroGPU kills the call on timeout.
        for _, model in MODELS.values():
            if next(model.parameters()).device.type != "cuda":
                model.to("cuda")
        return _run(prompt, max_new_tokens, seed)

    def run_comparison(
        prompt: str, max_new_tokens: int, seed: int
    ) -> tuple[str, str, str, str, str]:
        load_models(gpu_dtype=True)
        return _gpu_run(prompt, max_new_tokens, seed)

else:  # local: plain cpu/cuda execution

    def run_comparison(
        prompt: str, max_new_tokens: int, seed: int
    ) -> tuple[str, str, str, str, str]:
        load_models()
        device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        for _, model in MODELS.values():
            model.to(device)
        return _run(prompt, max_new_tokens, seed)