lmscope 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lmscope-0.1.0/LICENSE +21 -0
- lmscope-0.1.0/PKG-INFO +97 -0
- lmscope-0.1.0/README.md +73 -0
- lmscope-0.1.0/lmscope/__init__.py +6 -0
- lmscope-0.1.0/lmscope/__main__.py +30 -0
- lmscope-0.1.0/lmscope/inference.py +213 -0
- lmscope-0.1.0/lmscope/models.py +159 -0
- lmscope-0.1.0/lmscope/server.py +91 -0
- lmscope-0.1.0/lmscope/static/index.html +452 -0
- lmscope-0.1.0/lmscope/tokenizers.py +81 -0
- lmscope-0.1.0/lmscope.egg-info/PKG-INFO +97 -0
- lmscope-0.1.0/lmscope.egg-info/SOURCES.txt +16 -0
- lmscope-0.1.0/lmscope.egg-info/dependency_links.txt +1 -0
- lmscope-0.1.0/lmscope.egg-info/entry_points.txt +2 -0
- lmscope-0.1.0/lmscope.egg-info/requires.txt +4 -0
- lmscope-0.1.0/lmscope.egg-info/top_level.txt +1 -0
- lmscope-0.1.0/pyproject.toml +39 -0
- lmscope-0.1.0/setup.cfg +4 -0
lmscope-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
lmscope-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lmscope
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Step through LSTM / Transformer language-model inference in the browser
|
|
5
|
+
Author: Michael
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Ashurali/lmscope
|
|
8
|
+
Project-URL: Issues, https://github.com/Ashurali/lmscope/issues
|
|
9
|
+
Keywords: language model,transformer,lstm,attention,visualization,interpretability
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Education
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: flask>=2.0
|
|
20
|
+
Requires-Dist: numpy
|
|
21
|
+
Requires-Dist: torch>=2.0
|
|
22
|
+
Requires-Dist: tokenizers>=0.13
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# lmscope
|
|
26
|
+
|
|
27
|
+
Step through token-by-token generation of a small LSTM or Transformer language model in the browser,
|
|
28
|
+
and watch the probability distribution, attention maps (Transformer) or gate activations (LSTM).
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install lmscope # from PyPI; or, from a clone: pip install -e .
|
|
32
|
+
lmscope serve --model transformer_char_best.pt --model-type transformer --tokenizer-type char
|
|
33
|
+
lmscope serve --model lstm_bpe2000_best.pt --model-type lstm \
|
|
34
|
+
--tokenizer bpe2000_tokenizer.json --tokenizer-type bpe
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import lmscope
|
|
39
|
+
lmscope.serve(model_path="transformer_char_best.pt", model_type="transformer", tokenizer_type="char", port=8765)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Options: `--prompt "ROMEO:\n"`, `--seed 0` (sampling seed, restored on Reset), `--reference valid.txt`
|
|
43
|
+
(preloads a reference continuation for per-token loss / perplexity), `--device cpu`, `--no-browser`.
|
|
44
|
+
|
|
45
|
+
## Checkpoint format
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
{
|
|
49
|
+
"model_state_dict": ...,
|
|
50
|
+
"model_config": {"model_type": "transformer" | "lstm", "vocab_size": int, "d_model": int,
|
|
51
|
+
"n_layers": int, "context_len": int, "dropout": float,
|
|
52
|
+
"n_heads": int, "d_ff": int, # Transformer
|
|
53
|
+
"hidden_size": int}, # LSTM
|
|
54
|
+
"tokenizer_type": "char" | "bpe",
|
|
55
|
+
"char_to_idx": {...}, "idx_to_char": {...}, # char
|
|
56
|
+
"bpe_tokenizer_json": str, # bpe (optional if --tokenizer is given)
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The tokenizer comes from `--tokenizer` if given, otherwise from the checkpoint.
|
|
61
|
+
|
|
62
|
+
## API
|
|
63
|
+
|
|
64
|
+
| Endpoint | Body | Returns |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `GET /api/model-info` | | model type, parameter count, layers, vocab size, context length |
|
|
67
|
+
| `GET /api/state` | | token sequence with per-token sampling probability and loss |
|
|
68
|
+
| `POST /api/set-prompt` | `{prompt}` | tokenized prompt (resets the sequence) |
|
|
69
|
+
| `POST /api/reset` | | back to the prompt; sampling seed restored |
|
|
70
|
+
| `POST /api/step` | `{position, temperature, layer, head, top_k}` | one forward pass: top-k distribution, entropy, top-k mass, attention matrix or LSTM gates, hidden-state norms |
|
|
71
|
+
| `POST /api/sample` | `{temperature}` | samples and appends the next token |
|
|
72
|
+
| `POST /api/score` | `{text}` | per-token cross-entropy and running perplexity of a reference continuation |
|
|
73
|
+
|
|
74
|
+
`position` p is the prediction of token p from the tokens before it (default: the next token).
|
|
75
|
+
|
|
76
|
+
## Internals
|
|
77
|
+
|
|
78
|
+
- **Transformer**: each attention module applies its softmax through a small `AttentionSoftmax`
|
|
79
|
+
submodule; a forward hook on it records the weights `(heads, T, T)` of every layer.
|
|
80
|
+
- **LSTM**: `CustomLSTMCell` re-runs each `nn.LSTM` layer step by step from its weights
|
|
81
|
+
(gate order input, forget, cell, output) and exposes the input / forget / output gates and the states.
|
|
82
|
+
|
|
83
|
+
PNG export happens in the browser: per-panel buttons save each canvas, and "Export page" uses
|
|
84
|
+
html2canvas (loaded from cdnjs, so it needs internet access).
|
|
85
|
+
|
|
86
|
+
## Releasing
|
|
87
|
+
|
|
88
|
+
The version lives in `lmscope/__init__.py` (`__version__`).
|
|
89
|
+
|
|
90
|
+
1. Bump `__version__`, commit and push.
|
|
91
|
+
2. Create a GitHub release with tag `vX.Y.Z`. The `Publish to PyPI` workflow builds the sdist and wheel,
|
|
92
|
+
runs `twine check`, and uploads with PyPI trusted publishing (no API token in the repo).
|
|
93
|
+
|
|
94
|
+
One-time setup on PyPI: under *Your projects → Publishing → Add a pending publisher*, enter owner `Ashurali`,
|
|
95
|
+
repository `lmscope`, workflow `publish.yml`, environment `pypi`.
|
|
96
|
+
|
|
97
|
+
Local build check: `python -m build && python -m twine check dist/*`.
|
lmscope-0.1.0/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# lmscope
|
|
2
|
+
|
|
3
|
+
Step through token-by-token generation of a small LSTM or Transformer language model in the browser,
|
|
4
|
+
and watch the probability distribution, attention maps (Transformer) or gate activations (LSTM).
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install lmscope # from PyPI; or, from a clone: pip install -e .
|
|
8
|
+
lmscope serve --model transformer_char_best.pt --model-type transformer --tokenizer-type char
|
|
9
|
+
lmscope serve --model lstm_bpe2000_best.pt --model-type lstm \
|
|
10
|
+
--tokenizer bpe2000_tokenizer.json --tokenizer-type bpe
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import lmscope
|
|
15
|
+
lmscope.serve(model_path="transformer_char_best.pt", model_type="transformer", tokenizer_type="char", port=8765)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Options: `--prompt "ROMEO:\n"`, `--seed 0` (sampling seed, restored on Reset), `--reference valid.txt`
|
|
19
|
+
(preloads a reference continuation for per-token loss / perplexity), `--device cpu`, `--no-browser`.
|
|
20
|
+
|
|
21
|
+
## Checkpoint format
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
{
|
|
25
|
+
"model_state_dict": ...,
|
|
26
|
+
"model_config": {"model_type": "transformer" | "lstm", "vocab_size": int, "d_model": int,
|
|
27
|
+
"n_layers": int, "context_len": int, "dropout": float,
|
|
28
|
+
"n_heads": int, "d_ff": int, # Transformer
|
|
29
|
+
"hidden_size": int}, # LSTM
|
|
30
|
+
"tokenizer_type": "char" | "bpe",
|
|
31
|
+
"char_to_idx": {...}, "idx_to_char": {...}, # char
|
|
32
|
+
"bpe_tokenizer_json": str, # bpe (optional if --tokenizer is given)
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The tokenizer comes from `--tokenizer` if given, otherwise from the checkpoint.
|
|
37
|
+
|
|
38
|
+
## API
|
|
39
|
+
|
|
40
|
+
| Endpoint | Body | Returns |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `GET /api/model-info` | | model type, parameter count, layers, vocab size, context length |
|
|
43
|
+
| `GET /api/state` | | token sequence with per-token sampling probability and loss |
|
|
44
|
+
| `POST /api/set-prompt` | `{prompt}` | tokenized prompt (resets the sequence) |
|
|
45
|
+
| `POST /api/reset` | | back to the prompt; sampling seed restored |
|
|
46
|
+
| `POST /api/step` | `{position, temperature, layer, head, top_k}` | one forward pass: top-k distribution, entropy, top-k mass, attention matrix or LSTM gates, hidden-state norms |
|
|
47
|
+
| `POST /api/sample` | `{temperature}` | samples and appends the next token |
|
|
48
|
+
| `POST /api/score` | `{text}` | per-token cross-entropy and running perplexity of a reference continuation |
|
|
49
|
+
|
|
50
|
+
`position` p is the prediction of token p from the tokens before it (default: the next token).
|
|
51
|
+
|
|
52
|
+
## Internals
|
|
53
|
+
|
|
54
|
+
- **Transformer**: each attention module applies its softmax through a small `AttentionSoftmax`
|
|
55
|
+
submodule; a forward hook on it records the weights `(heads, T, T)` of every layer.
|
|
56
|
+
- **LSTM**: `CustomLSTMCell` re-runs each `nn.LSTM` layer step by step from its weights
|
|
57
|
+
(gate order input, forget, cell, output) and exposes the input / forget / output gates and the states.
|
|
58
|
+
|
|
59
|
+
PNG export happens in the browser: per-panel buttons save each canvas, and "Export page" uses
|
|
60
|
+
html2canvas (loaded from cdnjs, so it needs internet access).
|
|
61
|
+
|
|
62
|
+
## Releasing
|
|
63
|
+
|
|
64
|
+
The version lives in `lmscope/__init__.py` (`__version__`).
|
|
65
|
+
|
|
66
|
+
1. Bump `__version__`, commit and push.
|
|
67
|
+
2. Create a GitHub release with tag `vX.Y.Z`. The `Publish to PyPI` workflow builds the sdist and wheel,
|
|
68
|
+
runs `twine check`, and uploads with PyPI trusted publishing (no API token in the repo).
|
|
69
|
+
|
|
70
|
+
One-time setup on PyPI: under *Your projects → Publishing → Add a pending publisher*, enter owner `Ashurali`,
|
|
71
|
+
repository `lmscope`, workflow `publish.yml`, environment `pypi`.
|
|
72
|
+
|
|
73
|
+
Local build check: `python -m build && python -m twine check dist/*`.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""lmscope: step through LSTM / Transformer language-model inference in the browser."""
|
|
2
|
+
from .inference import Inspector, load_checkpoint
|
|
3
|
+
from .server import create_app, serve
|
|
4
|
+
|
|
5
|
+
__all__ = ["serve", "create_app", "Inspector", "load_checkpoint"]
|
|
6
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""CLI: lmscope serve --model ckpt.pt [--model-type ...] [--tokenizer ...] [--tokenizer-type ...]"""
|
|
2
|
+
import argparse
|
|
3
|
+
|
|
4
|
+
from .server import serve
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main(argv=None):
|
|
8
|
+
parser = argparse.ArgumentParser(prog="lmscope", description="Interactive LM inference visualizer")
|
|
9
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
10
|
+
s = sub.add_parser("serve", help="load a checkpoint and open the browser UI")
|
|
11
|
+
s.add_argument("--model", required=True, help="checkpoint .pt in the lmscope format")
|
|
12
|
+
s.add_argument("--model-type", choices=["transformer", "lstm"], help="checked against the checkpoint")
|
|
13
|
+
s.add_argument("--tokenizer", help="tokenizer JSON (BPE: HuggingFace tokenizer file; char: char_to_idx)")
|
|
14
|
+
s.add_argument("--tokenizer-type", choices=["char", "bpe"], help="default: from the checkpoint")
|
|
15
|
+
s.add_argument("--port", type=int, default=8765)
|
|
16
|
+
s.add_argument("--host", default="127.0.0.1")
|
|
17
|
+
s.add_argument("--prompt", default="ROMEO:\n", help="initial prompt (\\n is a newline)")
|
|
18
|
+
s.add_argument("--seed", type=int, default=0, help="sampling seed, restored on reset")
|
|
19
|
+
s.add_argument("--reference", help="text file preloaded as the reference continuation for loss/perplexity")
|
|
20
|
+
s.add_argument("--device", help="cuda or cpu (default: cuda if available)")
|
|
21
|
+
s.add_argument("--no-browser", action="store_true", help="don't open a browser tab")
|
|
22
|
+
args = parser.parse_args(argv)
|
|
23
|
+
serve(model_path=args.model, model_type=args.model_type, tokenizer_path=args.tokenizer,
|
|
24
|
+
tokenizer_type=args.tokenizer_type, port=args.port, host=args.host,
|
|
25
|
+
prompt=args.prompt.replace("\\n", "\n"), seed=args.seed, reference_file=args.reference,
|
|
26
|
+
open_browser=not args.no_browser, device=args.device)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
main()
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Step-by-step inference with access to model internals.
|
|
2
|
+
|
|
3
|
+
`Inspector` keeps one token sequence (the prompt plus generated tokens). "Position p" means the
|
|
4
|
+
prediction of token p from the tokens before it, so p runs from 1 to len(sequence); p = len(sequence)
|
|
5
|
+
is the next, not-yet-sampled token. The model always sees at most `context_len` tokens before p.
|
|
6
|
+
"""
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
import torch
|
|
10
|
+
import torch.nn.functional as F
|
|
11
|
+
|
|
12
|
+
from .models import CausalTransformer, CustomLSTMCell, LSTMModel, build_model, count_params
|
|
13
|
+
from .tokenizers import load_tokenizer
|
|
14
|
+
|
|
15
|
+
LN2 = math.log(2)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_checkpoint(path, model_type=None, tokenizer_path=None, tokenizer_type=None, device=None):
|
|
19
|
+
"""Builds model and tokenizer from a checkpoint in the lmscope format (see README)."""
|
|
20
|
+
device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
|
21
|
+
ckpt = torch.load(path, map_location="cpu", weights_only=True)
|
|
22
|
+
if "model_state_dict" not in ckpt or "model_config" not in ckpt:
|
|
23
|
+
raise ValueError(f"{path} is not an lmscope checkpoint: it needs 'model_state_dict' and 'model_config'")
|
|
24
|
+
config = dict(ckpt["model_config"])
|
|
25
|
+
if model_type and model_type != config["model_type"]:
|
|
26
|
+
raise ValueError(f"--model-type {model_type} but the checkpoint holds a {config['model_type']}")
|
|
27
|
+
model = build_model(config)
|
|
28
|
+
model.load_state_dict(ckpt["model_state_dict"])
|
|
29
|
+
model.to(device).eval()
|
|
30
|
+
tokenizer = load_tokenizer(tokenizer_type, tokenizer_path, ckpt)
|
|
31
|
+
if tokenizer.vocab_size != config["vocab_size"]:
|
|
32
|
+
raise ValueError(f"tokenizer has {tokenizer.vocab_size} tokens, model expects {config['vocab_size']}")
|
|
33
|
+
return model, tokenizer, config
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _round(t, digits=4):
|
|
37
|
+
return [round(float(x), digits) for x in t]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Inspector:
|
|
41
|
+
def __init__(self, model, tokenizer, config, seed=0):
|
|
42
|
+
self.model = model
|
|
43
|
+
self.tok = tokenizer
|
|
44
|
+
self.config = config
|
|
45
|
+
self.context_len = config["context_len"]
|
|
46
|
+
self.device = next(model.parameters()).device
|
|
47
|
+
self.seed = seed
|
|
48
|
+
self.prompt_ids = []
|
|
49
|
+
self.ids = []
|
|
50
|
+
self.records = {} # position -> how the generated token there was sampled
|
|
51
|
+
self._gen = torch.Generator().manual_seed(seed)
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------ sequence state
|
|
54
|
+
def model_info(self):
|
|
55
|
+
c = self.config
|
|
56
|
+
return {"model_type": c["model_type"], "params": count_params(self.model), "n_layers": c["n_layers"],
|
|
57
|
+
"n_heads": c.get("n_heads"), "hidden_size": c.get("hidden_size"), "d_model": c["d_model"],
|
|
58
|
+
"vocab_size": c["vocab_size"], "context_len": self.context_len,
|
|
59
|
+
"tokenizer_type": self.tok.kind, "device": str(self.device)}
|
|
60
|
+
|
|
61
|
+
def set_prompt(self, text):
|
|
62
|
+
ids = self.tok.encode(text)
|
|
63
|
+
if not ids:
|
|
64
|
+
raise ValueError("the prompt is empty")
|
|
65
|
+
self.prompt_ids = ids
|
|
66
|
+
return self.reset()
|
|
67
|
+
|
|
68
|
+
def reset(self):
|
|
69
|
+
self.ids = list(self.prompt_ids)
|
|
70
|
+
self.records = {}
|
|
71
|
+
self._gen = torch.Generator().manual_seed(self.seed)
|
|
72
|
+
return self.state()
|
|
73
|
+
|
|
74
|
+
def state(self):
|
|
75
|
+
tokens = []
|
|
76
|
+
for p, i in enumerate(self.ids):
|
|
77
|
+
rec = self.records.get(p)
|
|
78
|
+
tokens.append({"id": i, "token": self.tok.token_str(i), "generated": p >= len(self.prompt_ids),
|
|
79
|
+
"prob": rec["prob"] if rec else None, "loss": rec["loss"] if rec else None})
|
|
80
|
+
losses = [r["loss"] for r in self.records.values()]
|
|
81
|
+
return {"tokens": tokens, "prompt_len": len(self.prompt_ids), "length": len(self.ids),
|
|
82
|
+
"generated": len(losses), "text": self.tok.decode(self.ids),
|
|
83
|
+
"perplexity": math.exp(sum(losses) / len(losses)) if losses else None}
|
|
84
|
+
|
|
85
|
+
def _window(self, position):
|
|
86
|
+
start = max(0, position - self.context_len)
|
|
87
|
+
return start, self.ids[start:position]
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------ forward passes
|
|
90
|
+
@torch.no_grad()
|
|
91
|
+
def _logits(self, ids):
|
|
92
|
+
x = torch.tensor([ids], device=self.device)
|
|
93
|
+
out = self.model(x)
|
|
94
|
+
return (out[0] if isinstance(self.model, LSTMModel) else out)[0].float()
|
|
95
|
+
|
|
96
|
+
def _distribution(self, logits, temperature, top_k, chosen=None):
|
|
97
|
+
probs = F.softmax(logits / temperature, dim=-1).cpu()
|
|
98
|
+
entropy = float(-(probs * torch.log(probs.clamp_min(1e-12))).sum()) / LN2
|
|
99
|
+
top_p, top_i = probs.topk(min(top_k, probs.numel()))
|
|
100
|
+
mass = torch.cumsum(probs.sort(descending=True).values, 0)
|
|
101
|
+
out = {"temperature": temperature,
|
|
102
|
+
"top": [{"id": int(i), "token": self.tok.token_str(i), "prob": round(float(p), 6)}
|
|
103
|
+
for p, i in zip(top_p, top_i)],
|
|
104
|
+
"entropy_bits": round(entropy, 4), "max_entropy_bits": round(math.log2(probs.numel()), 4),
|
|
105
|
+
"topk_mass": {k: round(float(mass[k - 1]), 4) for k in (1, 5, 10, 20) if k <= probs.numel()},
|
|
106
|
+
"chosen": None}
|
|
107
|
+
if chosen is not None:
|
|
108
|
+
out["chosen"] = {"id": chosen, "token": self.tok.token_str(chosen),
|
|
109
|
+
"prob": round(float(probs[chosen]), 6),
|
|
110
|
+
"rank": int((probs > probs[chosen]).sum()) + 1}
|
|
111
|
+
return out, probs
|
|
112
|
+
|
|
113
|
+
@torch.no_grad()
|
|
114
|
+
def _transformer_internals(self, ids, layer, head):
|
|
115
|
+
"""One forward pass with hooks: attention of every layer, residual-stream norms per layer."""
|
|
116
|
+
model = self.model
|
|
117
|
+
attn, norms, hooks = [], [], []
|
|
118
|
+
for block in model.blocks:
|
|
119
|
+
hooks.append(block.attn.softmax.register_forward_hook(lambda m, i, o: attn.append(o[0])))
|
|
120
|
+
hooks.append(block.register_forward_hook(lambda m, i, o: norms.append(o[0].norm(dim=-1))))
|
|
121
|
+
try:
|
|
122
|
+
x = torch.tensor([ids], device=self.device)
|
|
123
|
+
emb = model.tok_embed(x) + model.pos_embed(torch.arange(len(ids), device=self.device))
|
|
124
|
+
norms.append(emb[0].norm(dim=-1))
|
|
125
|
+
model(x)
|
|
126
|
+
finally:
|
|
127
|
+
for h in hooks:
|
|
128
|
+
h.remove()
|
|
129
|
+
# norms: embedding first, then the output of each block (hooks fire in block order).
|
|
130
|
+
a = attn[layer] # (heads, T, T)
|
|
131
|
+
matrix = a.mean(0) if head == "mean" else a[int(head)]
|
|
132
|
+
# Where each head of this layer looks, for the last query: mean attention distance in tokens.
|
|
133
|
+
T = len(ids)
|
|
134
|
+
dist = (torch.arange(T, device=a.device).flip(0).float() * a[:, -1, :]).sum(-1)
|
|
135
|
+
return {"kind": "attention", "layer": layer, "head": head, "n_layers": len(attn), "n_heads": a.shape[0],
|
|
136
|
+
"matrix": [_round(row, 4) for row in matrix.cpu()],
|
|
137
|
+
"last_row_by_head": [_round(row, 4) for row in a[:, -1, :].cpu()],
|
|
138
|
+
"head_mean_distance": _round(dist.cpu(), 2),
|
|
139
|
+
"norm_labels": ["embedding"] + [f"block {i + 1}" for i in range(len(attn))],
|
|
140
|
+
"norms": [_round(n.cpu(), 3) for n in norms]}
|
|
141
|
+
|
|
142
|
+
@torch.no_grad()
|
|
143
|
+
def _lstm_internals(self, ids, layer):
|
|
144
|
+
"""Re-runs the LSTM layer by layer with CustomLSTMCell to expose gates and states."""
|
|
145
|
+
model = self.model
|
|
146
|
+
x = model.embed(torch.tensor(ids, device=self.device))
|
|
147
|
+
layers = []
|
|
148
|
+
for l in range(model.lstm.num_layers):
|
|
149
|
+
h, c, gates = CustomLSTMCell(model.lstm, l).run(x)
|
|
150
|
+
layers.append((h, c, gates))
|
|
151
|
+
x = h # inter-layer dropout is off in eval mode
|
|
152
|
+
h, c, gates = layers[layer]
|
|
153
|
+
return {"kind": "gates", "layer": layer, "n_layers": len(layers), "hidden_size": h.shape[1],
|
|
154
|
+
"gates": {name: _round(g[-1].cpu(), 3) for name, g in gates.items()},
|
|
155
|
+
"gate_means": {name: round(float(g[-1].mean()), 4) for name, g in gates.items()},
|
|
156
|
+
"gate_means_over_time": {name: _round(g.mean(-1).cpu(), 4) for name, g in gates.items()},
|
|
157
|
+
"norm_labels": [f"h, layer {i + 1}" for i in range(len(layers))],
|
|
158
|
+
"norms": [_round(hh.norm(dim=-1).cpu(), 3) for hh, _, _ in layers],
|
|
159
|
+
"cell_norms": [_round(cc.norm(dim=-1).cpu(), 3) for _, cc, _ in layers]}
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------ public API
|
|
162
|
+
def analyze(self, position=None, temperature=1.0, layer=0, head="mean", top_k=20):
|
|
163
|
+
"""Distribution and internals for predicting the token at `position` (default: the next one)."""
|
|
164
|
+
position = len(self.ids) if position is None else int(position)
|
|
165
|
+
if not 1 <= position <= len(self.ids):
|
|
166
|
+
raise ValueError(f"position must be between 1 and {len(self.ids)}")
|
|
167
|
+
start, window = self._window(position)
|
|
168
|
+
chosen = self.ids[position] if position < len(self.ids) else None
|
|
169
|
+
dist, _ = self._distribution(self._logits(window)[-1], temperature, top_k, chosen)
|
|
170
|
+
if isinstance(self.model, CausalTransformer):
|
|
171
|
+
internals = self._transformer_internals(window, int(layer), head)
|
|
172
|
+
else:
|
|
173
|
+
internals = self._lstm_internals(window, int(layer))
|
|
174
|
+
internals["labels"] = [self.tok.token_str(i) for i in window]
|
|
175
|
+
return {"position": position, "window_start": start, "distribution": dist, "internals": internals,
|
|
176
|
+
"chosen_generated": position in self.records, "record": self.records.get(position)}
|
|
177
|
+
|
|
178
|
+
def sample(self, temperature=1.0):
|
|
179
|
+
"""Samples the next token, appends it and returns how it was chosen."""
|
|
180
|
+
position = len(self.ids)
|
|
181
|
+
_, window = self._window(position)
|
|
182
|
+
logits = self._logits(window)[-1].cpu()
|
|
183
|
+
probs = F.softmax(logits / temperature, dim=-1)
|
|
184
|
+
nxt = int(torch.multinomial(probs, 1, generator=self._gen))
|
|
185
|
+
p1 = F.softmax(logits, dim=-1)[nxt] # loss is always reported at T = 1
|
|
186
|
+
self.records[position] = {"prob": round(float(probs[nxt]), 6), "temperature": temperature,
|
|
187
|
+
"loss": round(float(-torch.log(p1)), 5)}
|
|
188
|
+
self.ids.append(nxt)
|
|
189
|
+
return {"position": position, "id": nxt, "token": self.tok.token_str(nxt), **self.records[position]}
|
|
190
|
+
|
|
191
|
+
@torch.no_grad()
|
|
192
|
+
def score(self, text):
|
|
193
|
+
"""Per-token cross-entropy (T = 1) of a reference continuation of the prompt, teacher-forced."""
|
|
194
|
+
ref = self.tok.encode(text)
|
|
195
|
+
if not ref:
|
|
196
|
+
raise ValueError("the reference text is empty")
|
|
197
|
+
seq = self.prompt_ids + ref
|
|
198
|
+
n_prompt = len(self.prompt_ids)
|
|
199
|
+
if len(seq) - 1 <= self.context_len:
|
|
200
|
+
pred = self._logits(seq[:-1])[n_prompt - 1:] # one pass covers every prediction
|
|
201
|
+
else: # longer than the context: one sliding window per predicted token
|
|
202
|
+
pred = torch.stack([self._logits(seq[max(0, p - self.context_len):p])[-1]
|
|
203
|
+
for p in range(n_prompt, len(seq))])
|
|
204
|
+
targets = torch.tensor(seq[n_prompt:], device=pred.device)
|
|
205
|
+
losses = F.cross_entropy(pred, targets, reduction="none").tolist()
|
|
206
|
+
running, total = [], 0.0
|
|
207
|
+
for k, l in enumerate(losses, 1):
|
|
208
|
+
total += l
|
|
209
|
+
running.append(round(math.exp(total / k), 4))
|
|
210
|
+
return {"tokens": [self.tok.token_str(i) for i in ref], "losses": _round(losses, 5),
|
|
211
|
+
"running_perplexity": running, "mean_loss": round(total / len(losses), 5),
|
|
212
|
+
"perplexity": running[-1],
|
|
213
|
+
"bits_per_char": round(total / LN2 / len(self.tok.decode(ref)), 4)}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Model architectures, copied from the HW3 code so its checkpoints load unchanged.
|
|
2
|
+
|
|
3
|
+
Parameter names must stay identical to HW3 (`embed`, `lstm`, `head` for the LSTM; `tok_embed`,
|
|
4
|
+
`pos_embed`, `blocks.N.{ln1,attn.qkv,attn.proj,ln2,ff.net}`, `ln_f`, `head` for the Transformer).
|
|
5
|
+
Two additions that add no parameters:
|
|
6
|
+
* MultiHeadSelfAttention applies its softmax through an `AttentionSoftmax` submodule, so a forward
|
|
7
|
+
hook on that submodule sees the attention weights.
|
|
8
|
+
* CustomLSTMCell re-runs one nn.LSTM layer step by step from its weights and exposes the gates.
|
|
9
|
+
"""
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
import torch
|
|
13
|
+
import torch.nn as nn
|
|
14
|
+
import torch.nn.functional as F
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LSTMModel(nn.Module):
|
|
18
|
+
"""Embedding -> multi-layer nn.LSTM -> dropout -> Linear."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, vocab_size, d_model=512, hidden_size=1024, n_layers=3, dropout=0.3, **_):
|
|
21
|
+
super().__init__()
|
|
22
|
+
self.embed = nn.Embedding(vocab_size, d_model)
|
|
23
|
+
self.lstm = nn.LSTM(d_model, hidden_size, num_layers=n_layers, dropout=dropout, batch_first=True)
|
|
24
|
+
self.drop = nn.Dropout(dropout)
|
|
25
|
+
self.head = nn.Linear(hidden_size, vocab_size)
|
|
26
|
+
|
|
27
|
+
def forward(self, x, hidden=None):
|
|
28
|
+
out, hidden = self.lstm(self.embed(x), hidden)
|
|
29
|
+
return self.head(self.drop(out)), hidden
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CustomLSTMCell:
|
|
33
|
+
"""One layer of an nn.LSTM, evaluated step by step with its gates exposed.
|
|
34
|
+
|
|
35
|
+
nn.LSTM stacks the gate weights in the order (input, forget, cell candidate, output):
|
|
36
|
+
gates = W_ih x_t + b_ih + W_hh h_{t-1} + b_hh
|
|
37
|
+
i, f, o = sigmoid(...), g = tanh(...)
|
|
38
|
+
c_t = f * c_{t-1} + i * g, h_t = o * tanh(c_t)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, lstm, layer):
|
|
42
|
+
self.w_ih = getattr(lstm, f"weight_ih_l{layer}")
|
|
43
|
+
self.w_hh = getattr(lstm, f"weight_hh_l{layer}")
|
|
44
|
+
self.b = getattr(lstm, f"bias_ih_l{layer}") + getattr(lstm, f"bias_hh_l{layer}")
|
|
45
|
+
self.hidden_size = lstm.hidden_size
|
|
46
|
+
|
|
47
|
+
@torch.no_grad()
|
|
48
|
+
def run(self, x):
|
|
49
|
+
"""x: (T, input_size). Returns h (T, H), c (T, H) and gates {"input","forget","output"} each (T, H)."""
|
|
50
|
+
T, H = x.shape[0], self.hidden_size
|
|
51
|
+
h = x.new_zeros(H)
|
|
52
|
+
c = x.new_zeros(H)
|
|
53
|
+
x_proj = x @ self.w_ih.T + self.b # input part of all steps at once
|
|
54
|
+
hs, cs, gi, gf, go = [], [], [], [], []
|
|
55
|
+
for t in range(T):
|
|
56
|
+
gates = x_proj[t] + self.w_hh @ h
|
|
57
|
+
i, f, g, o = gates.split(H)
|
|
58
|
+
i, f, o, g = torch.sigmoid(i), torch.sigmoid(f), torch.sigmoid(o), torch.tanh(g)
|
|
59
|
+
c = f * c + i * g
|
|
60
|
+
h = o * torch.tanh(c)
|
|
61
|
+
hs.append(h); cs.append(c); gi.append(i); gf.append(f); go.append(o)
|
|
62
|
+
stack = torch.stack
|
|
63
|
+
return stack(hs), stack(cs), {"input": stack(gi), "forget": stack(gf), "output": stack(go)}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AttentionSoftmax(nn.Module):
|
|
67
|
+
"""Softmax over the key axis. A module of its own so forward hooks can read attention weights."""
|
|
68
|
+
|
|
69
|
+
def forward(self, scores):
|
|
70
|
+
return F.softmax(scores, dim=-1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class MultiHeadSelfAttention(nn.Module):
|
|
74
|
+
def __init__(self, d_model, n_heads, context_len, dropout):
|
|
75
|
+
super().__init__()
|
|
76
|
+
assert d_model % n_heads == 0
|
|
77
|
+
self.n_heads = n_heads
|
|
78
|
+
self.d_k = d_model // n_heads
|
|
79
|
+
self.qkv = nn.Linear(d_model, 3 * d_model)
|
|
80
|
+
self.proj = nn.Linear(d_model, d_model)
|
|
81
|
+
self.softmax = AttentionSoftmax()
|
|
82
|
+
self.attn_drop = nn.Dropout(dropout)
|
|
83
|
+
self.resid_drop = nn.Dropout(dropout)
|
|
84
|
+
mask = torch.triu(torch.ones(context_len, context_len), diagonal=1).bool()
|
|
85
|
+
self.register_buffer("causal_mask", mask, persistent=False)
|
|
86
|
+
|
|
87
|
+
def forward(self, x):
|
|
88
|
+
B, T, C = x.shape
|
|
89
|
+
q, k, v = self.qkv(x).split(C, dim=2)
|
|
90
|
+
q = q.view(B, T, self.n_heads, self.d_k).transpose(1, 2)
|
|
91
|
+
k = k.view(B, T, self.n_heads, self.d_k).transpose(1, 2)
|
|
92
|
+
v = v.view(B, T, self.n_heads, self.d_k).transpose(1, 2)
|
|
93
|
+
scores = q @ k.transpose(-2, -1) / math.sqrt(self.d_k)
|
|
94
|
+
scores = scores.masked_fill(self.causal_mask[:T, :T], float("-inf"))
|
|
95
|
+
attn = self.softmax(scores) # (B, heads, T, T)
|
|
96
|
+
out = self.attn_drop(attn) @ v
|
|
97
|
+
out = out.transpose(1, 2).contiguous().view(B, T, C)
|
|
98
|
+
return self.resid_drop(self.proj(out))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class FeedForward(nn.Module):
|
|
102
|
+
def __init__(self, d_model, d_ff, dropout):
|
|
103
|
+
super().__init__()
|
|
104
|
+
self.net = nn.Sequential(nn.Linear(d_model, d_ff), nn.GELU(), nn.Linear(d_ff, d_model), nn.Dropout(dropout))
|
|
105
|
+
|
|
106
|
+
def forward(self, x):
|
|
107
|
+
return self.net(x)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TransformerBlock(nn.Module):
|
|
111
|
+
"""Pre-norm block: x + Attn(LN(x)), then x + FFN(LN(x))."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, d_model, n_heads, d_ff, context_len, dropout):
|
|
114
|
+
super().__init__()
|
|
115
|
+
self.ln1 = nn.LayerNorm(d_model)
|
|
116
|
+
self.attn = MultiHeadSelfAttention(d_model, n_heads, context_len, dropout)
|
|
117
|
+
self.ln2 = nn.LayerNorm(d_model)
|
|
118
|
+
self.ff = FeedForward(d_model, d_ff, dropout)
|
|
119
|
+
|
|
120
|
+
def forward(self, x):
|
|
121
|
+
x = x + self.attn(self.ln1(x))
|
|
122
|
+
return x + self.ff(self.ln2(x))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class CausalTransformer(nn.Module):
|
|
126
|
+
"""Decoder-only Transformer with learned positional embeddings."""
|
|
127
|
+
|
|
128
|
+
def __init__(self, vocab_size, d_model=512, n_heads=8, n_layers=6, d_ff=2048, context_len=256,
|
|
129
|
+
dropout=0.1, **_):
|
|
130
|
+
super().__init__()
|
|
131
|
+
self.context_len = context_len
|
|
132
|
+
self.tok_embed = nn.Embedding(vocab_size, d_model)
|
|
133
|
+
self.pos_embed = nn.Embedding(context_len, d_model)
|
|
134
|
+
self.drop = nn.Dropout(dropout)
|
|
135
|
+
self.blocks = nn.ModuleList(
|
|
136
|
+
[TransformerBlock(d_model, n_heads, d_ff, context_len, dropout) for _ in range(n_layers)])
|
|
137
|
+
self.ln_f = nn.LayerNorm(d_model)
|
|
138
|
+
self.head = nn.Linear(d_model, vocab_size)
|
|
139
|
+
|
|
140
|
+
def forward(self, x):
|
|
141
|
+
T = x.shape[1]
|
|
142
|
+
h = self.drop(self.tok_embed(x) + self.pos_embed(torch.arange(T, device=x.device)))
|
|
143
|
+
for block in self.blocks:
|
|
144
|
+
h = block(h)
|
|
145
|
+
return self.head(self.ln_f(h))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_model(config):
|
|
149
|
+
"""Builds an untrained model from a checkpoint's model_config."""
|
|
150
|
+
kind = config["model_type"]
|
|
151
|
+
if kind == "lstm":
|
|
152
|
+
return LSTMModel(**config)
|
|
153
|
+
if kind == "transformer":
|
|
154
|
+
return CausalTransformer(**config)
|
|
155
|
+
raise ValueError(f"unknown model_type {kind!r}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def count_params(model):
|
|
159
|
+
return sum(p.numel() for p in model.parameters())
|