initium-deeplearning 0.1.0__py3-none-any.whl
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.
- initium/LNN_controller/cfc_backbone_parallel.py +265 -0
- initium/LNN_controller/cfc_controller.py +325 -0
- initium/LNN_controller/chained_controller.py +83 -0
- initium/LNN_controller/hybrid_controller.py +90 -0
- initium/MoE/README.md +1 -0
- initium/MoE/moe_layer.py +552 -0
- initium/__init__.py +1 -0
- initium/config/controller_config.py +86 -0
- initium/config/train_config.py +64 -0
- initium/core_training.py +2182 -0
- initium/data/__init__.py +0 -0
- initium/data/audio/__init__.py +0 -0
- initium/data/audio/audio_dataset.py +96 -0
- initium/data/base_dataset.py +75 -0
- initium/data/common/__init__.py +0 -0
- initium/data/common/chain_task.py +542 -0
- initium/data/common/digit_codec.py +85 -0
- initium/data/common/graph_io.py +123 -0
- initium/data/common/real_data.py +747 -0
- initium/data/dataset_registry.py +59 -0
- initium/data/graph_traversal/__init__.py +0 -0
- initium/data/graph_traversal/graph_traversal.py +746 -0
- initium/data/multimodal/__init__.py +0 -0
- initium/data/multimodal/multimodal_dataset.py +401 -0
- initium/data/text/__init__.py +0 -0
- initium/data/text/text_dataset.py +79 -0
- initium/data/video/__init__.py +0 -0
- initium/data/video/video_dataset.py +85 -0
- initium/inference/__init__.py +1 -0
- initium/inference/cache/__init__.py +1 -0
- initium/inference/cache/base_cache.py +112 -0
- initium/inference/cache/cache_config.py +21 -0
- initium/inference/cache/cache_registry.py +99 -0
- initium/inference/cache/cached_engine.py +264 -0
- initium/inference/cache/keys.py +77 -0
- initium/inference/cache/prefix_cache.py +59 -0
- initium/inference/cache/result_cache.py +57 -0
- initium/inference/cache/state_utils.py +74 -0
- initium/inference/cache/stores.py +167 -0
- initium/inference/capabilities.py +86 -0
- initium/inference/checkpoint_io.py +63 -0
- initium/inference/engine.py +88 -0
- initium/inference/inference_config.py +22 -0
- initium/inference/metrics.py +103 -0
- initium/inference/model_loader.py +270 -0
- initium/inference/run_inference.py +526 -0
- initium/inference/run_logging.py +173 -0
- initium/inference/tasks/__init__.py +1 -0
- initium/inference/tasks/audio_task.py +114 -0
- initium/inference/tasks/base_task.py +60 -0
- initium/inference/tasks/graph_traversal_task.py +235 -0
- initium/inference/tasks/multimodal_task.py +74 -0
- initium/inference/tasks/task_registry.py +68 -0
- initium/inference/tasks/text_task.py +114 -0
- initium/inference/tasks/video_task.py +114 -0
- initium/mamba_controller/mamba2_controller.py +433 -0
- initium/mamba_controller/mamba3_controller.py +242 -0
- initium/mamba_controller/mamba_backbone_parallel.py +291 -0
- initium/mamba_controller/mamba_controller.py +983 -0
- initium/mamba_controller/split_graph_dnc.py +623 -0
- initium/memory_manipulation/dynamic_memory_resize.py +157 -0
- initium/memory_manipulation/dynamic_n_controller.py +152 -0
- initium/memory_manipulation/link_matrix_ablation.py +130 -0
- initium/memory_manipulation/nvrtc_compat.py +143 -0
- initium/memory_manipulation/stochastic_write_head_v2.py +545 -0
- initium/setup_wheels.py +175 -0
- initium_deeplearning-0.1.0.dist-info/METADATA +253 -0
- initium_deeplearning-0.1.0.dist-info/RECORD +71 -0
- initium_deeplearning-0.1.0.dist-info/WHEEL +4 -0
- initium_deeplearning-0.1.0.dist-info/entry_points.txt +3 -0
- initium_deeplearning-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
"""
|
|
2
|
+
file: data/common/real_data.py
|
|
3
|
+
|
|
4
|
+
Turns a modality-specific raw source (a text corpus, an audio file, a video
|
|
5
|
+
file) into the ONE thing every data/common/chain_task.py KVChainDataset
|
|
6
|
+
needs: a pool of (key, value) integer-label facts, split into a TRAIN pool
|
|
7
|
+
(sampled from during curriculum training, replacing the synthetic
|
|
8
|
+
rng.sample(range(label_range), ...) draw) and a TEST pool (disjoint keys,
|
|
9
|
+
never seen during training - the modality's analogue of the graph
|
|
10
|
+
dataset's held-out London Underground graph; see graph_io.py for that one,
|
|
11
|
+
which is edge-structured rather than token-structured so it doesn't fit
|
|
12
|
+
this module's pipeline).
|
|
13
|
+
|
|
14
|
+
Pipeline, same for every modality (this is deliberate - see the "reduce
|
|
15
|
+
every modality to an integer token stream first" idea from the research
|
|
16
|
+
notes):
|
|
17
|
+
|
|
18
|
+
raw source -> per-unit integer token id in [0, label_range)
|
|
19
|
+
-> consecutive-pair (bigram) counts
|
|
20
|
+
-> most-common value per key -> fact pool
|
|
21
|
+
-> disjoint train/test key split
|
|
22
|
+
|
|
23
|
+
What differs per modality is only the FIRST step (raw source -> token
|
|
24
|
+
stream): BPE byte-pair merges for text, quantized spectrogram-frame bins
|
|
25
|
+
for audio, quantized downsampled-frame intensities for video. Each
|
|
26
|
+
modality's dataset module (text_dataset.py / audio_dataset.py /
|
|
27
|
+
video_dataset.py) calls its own `*_token_stream()` function below and then
|
|
28
|
+
the two shared helpers (`build_kv_pool_from_tokens`, `split_train_test_facts`).
|
|
29
|
+
|
|
30
|
+
None of this is meant to be a state-of-the-art tokenizer/codec - it's the
|
|
31
|
+
minimum real-data pipeline that plugs into the existing digit-coded KV
|
|
32
|
+
chain-task machinery unmodified. Swapping in a production BPE
|
|
33
|
+
implementation, a real mel-spectrogram front end, or a real video encoder
|
|
34
|
+
later only means replacing the token-stream function; build_kv_pool_from_tokens
|
|
35
|
+
/ split_train_test_facts and everything downstream (chain_task.py,
|
|
36
|
+
training, evaluation) stays the same.
|
|
37
|
+
|
|
38
|
+
Note: audio real-data loading now streams (see _iter_waveform_chunks /
|
|
39
|
+
audio_token_stream_chunks / build_audio_kv_pool below) -- the whole-file
|
|
40
|
+
torch.stft call this used to require was the actual cause of OOMs on
|
|
41
|
+
multi-hour sources (a 2-hour recording's single torch.stft call alone was
|
|
42
|
+
several GB); it is now computed chunk_seconds at a time instead.
|
|
43
|
+
audio_token_stream() (the original whole-file function) is kept as-is for
|
|
44
|
+
small files / direct use, but build_audio_kv_pool() no longer calls it.
|
|
45
|
+
Decoding goes through the `ffmpeg`/`ffprobe` CLI directly rather than
|
|
46
|
+
torchaudio's I/O backend, whose legacy info()/load() API has been removed
|
|
47
|
+
or changed across torchaudio releases -- see _require_ffmpeg()'s docstring.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import glob as _glob
|
|
53
|
+
import os
|
|
54
|
+
import random
|
|
55
|
+
from collections import Counter
|
|
56
|
+
|
|
57
|
+
LABEL_RANGE = 1000
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_link_paths(link) -> list[str]:
|
|
61
|
+
"""Expand a dataset link into a sorted, de-duplicated list of one or
|
|
62
|
+
more file paths. Accepts: a single file path; a directory (every
|
|
63
|
+
regular file directly inside it is used -- e.g. "/data/audio_clips" to
|
|
64
|
+
pull in a whole folder); a glob pattern (e.g. "/data/audio_clips/*.wav");
|
|
65
|
+
or several of these '+'-joined in one string (e.g.
|
|
66
|
+
"clip_a.wav+clip_b.wav"), or an actual list/tuple of specs.
|
|
67
|
+
|
|
68
|
+
Each resolved path is treated as an independent SOURCE by the
|
|
69
|
+
build_*_kv_pool() functions below: its own token stream/bigram counts
|
|
70
|
+
are folded into the SAME shared pool, but never stitched across a file
|
|
71
|
+
boundary the way within-file chunking is (see _accumulate_bigram_chunk)
|
|
72
|
+
-- two unrelated files (e.g. 5 audio clips with different frequency
|
|
73
|
+
content) have no real adjacency between them, so treating their
|
|
74
|
+
boundary as a genuine bigram would inject spurious facts. This is the
|
|
75
|
+
single place multi-file/"pass a whole folder" support lives; every
|
|
76
|
+
caller (text/audio/video real-data pools, and data/common/graph_io.py's
|
|
77
|
+
multi-file edge loading) goes through this function, so no per-modality
|
|
78
|
+
globbing logic is duplicated.
|
|
79
|
+
|
|
80
|
+
Sorted (not glob's arbitrary OS order) so the resulting pool is
|
|
81
|
+
reproducible across runs/machines for the same directory/pattern.
|
|
82
|
+
"""
|
|
83
|
+
if isinstance(link, list | tuple):
|
|
84
|
+
specs = list(link)
|
|
85
|
+
else:
|
|
86
|
+
specs = str(link).split("+")
|
|
87
|
+
|
|
88
|
+
paths: list[str] = []
|
|
89
|
+
for spec in specs:
|
|
90
|
+
spec = spec.strip()
|
|
91
|
+
if not spec:
|
|
92
|
+
continue
|
|
93
|
+
if any(ch in spec for ch in "*?["):
|
|
94
|
+
matches = sorted(p for p in _glob.glob(spec) if os.path.isfile(p))
|
|
95
|
+
if not matches:
|
|
96
|
+
raise FileNotFoundError(f"resolve_link_paths: glob {spec!r} matched no files")
|
|
97
|
+
paths.extend(matches)
|
|
98
|
+
elif os.path.isdir(spec):
|
|
99
|
+
matches = sorted(p for p in _glob.glob(os.path.join(spec, "*")) if os.path.isfile(p))
|
|
100
|
+
if not matches:
|
|
101
|
+
raise FileNotFoundError(f"resolve_link_paths: directory {spec!r} contains no files")
|
|
102
|
+
paths.extend(matches)
|
|
103
|
+
elif os.path.isfile(spec):
|
|
104
|
+
paths.append(spec)
|
|
105
|
+
else:
|
|
106
|
+
raise FileNotFoundError(
|
|
107
|
+
f"resolve_link_paths: {spec!r} is not a file, directory, or glob match"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
seen, deduped = set(), []
|
|
111
|
+
for p in paths:
|
|
112
|
+
if p not in seen:
|
|
113
|
+
seen.add(p)
|
|
114
|
+
deduped.append(p)
|
|
115
|
+
if not deduped:
|
|
116
|
+
raise FileNotFoundError(f"resolve_link_paths: {link!r} resolved to no files")
|
|
117
|
+
return deduped
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
import numpy as np
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
from tokenizers import Tokenizer as _HFTokenizer
|
|
124
|
+
from tokenizers.models import BPE as _HFBPEModel
|
|
125
|
+
from tokenizers.pre_tokenizers import ByteLevel as _HFByteLevel
|
|
126
|
+
from tokenizers.trainers import BpeTrainer as _HFBpeTrainer
|
|
127
|
+
|
|
128
|
+
_HF_TOKENIZERS_AVAILABLE = True
|
|
129
|
+
except ImportError:
|
|
130
|
+
_HF_TOKENIZERS_AVAILABLE = False
|
|
131
|
+
|
|
132
|
+
# BPE trainer is O(corpus_len * merges) in pure python -- fine for a
|
|
133
|
+
# "reasonable synthetic-real" corpus, not meant for GB-scale text. Cap the
|
|
134
|
+
# training sample so a huge file doesn't hang the training script's startup.
|
|
135
|
+
_BPE_TRAIN_CHARS_CAP = 300_000
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ==========================================================================
|
|
139
|
+
# text: byte-level BPE -> integer token stream
|
|
140
|
+
# ==========================================================================
|
|
141
|
+
class BPETokenizer:
|
|
142
|
+
"""Minimal byte-level BPE (Sennrich et al. 2016). Vocab starts as the
|
|
143
|
+
256 raw bytes and merges the most frequent adjacent pair repeatedly
|
|
144
|
+
until `vocab_size` is reached. `vocab_size` defaults to LABEL_RANGE so
|
|
145
|
+
every emitted token id already fits DigitCodec's label_range=1000
|
|
146
|
+
without any further hashing/modulo -- the one thing this module's
|
|
147
|
+
callers rely on.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, vocab_size: int = LABEL_RANGE):
|
|
151
|
+
if vocab_size <= 256:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"BPETokenizer: vocab_size must be > 256 (raw bytes), got {vocab_size}"
|
|
154
|
+
)
|
|
155
|
+
self.vocab_size = vocab_size
|
|
156
|
+
self.merges: list[
|
|
157
|
+
tuple[int, int]
|
|
158
|
+
] = [] # ordered (a, b) merges, applied in order at encode time
|
|
159
|
+
|
|
160
|
+
def train(self, text: str) -> BPETokenizer:
|
|
161
|
+
data = text.encode("utf-8", errors="ignore")[:_BPE_TRAIN_CHARS_CAP]
|
|
162
|
+
tokens = list(data) # ids 0..255
|
|
163
|
+
next_id = 256
|
|
164
|
+
while next_id < self.vocab_size and len(tokens) > 1:
|
|
165
|
+
pairs = Counter(zip(tokens, tokens[1:]))
|
|
166
|
+
if not pairs:
|
|
167
|
+
break
|
|
168
|
+
(a, b), count = pairs.most_common(1)[0]
|
|
169
|
+
if count < 2:
|
|
170
|
+
break
|
|
171
|
+
merged = next_id
|
|
172
|
+
new_tokens, i = [], 0
|
|
173
|
+
while i < len(tokens):
|
|
174
|
+
if i < len(tokens) - 1 and tokens[i] == a and tokens[i + 1] == b:
|
|
175
|
+
new_tokens.append(merged)
|
|
176
|
+
i += 2
|
|
177
|
+
else:
|
|
178
|
+
new_tokens.append(tokens[i])
|
|
179
|
+
i += 1
|
|
180
|
+
tokens = new_tokens
|
|
181
|
+
self.merges.append((a, b))
|
|
182
|
+
next_id += 1
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
def encode(self, text: str) -> list[int]:
|
|
186
|
+
tokens = list(text.encode("utf-8", errors="ignore"))
|
|
187
|
+
next_id = 256
|
|
188
|
+
for a, b in self.merges:
|
|
189
|
+
merged = next_id
|
|
190
|
+
new_tokens, i = [], 0
|
|
191
|
+
while i < len(tokens):
|
|
192
|
+
if i < len(tokens) - 1 and tokens[i] == a and tokens[i + 1] == b:
|
|
193
|
+
new_tokens.append(merged)
|
|
194
|
+
i += 2
|
|
195
|
+
else:
|
|
196
|
+
new_tokens.append(tokens[i])
|
|
197
|
+
i += 1
|
|
198
|
+
tokens = new_tokens
|
|
199
|
+
next_id += 1
|
|
200
|
+
return tokens
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
_TEXT_STREAM_CHUNK_CHARS = 1_000_000 # read/encode in ~1M-char chunks -- the corpus is
|
|
204
|
+
# never materialized whole as one Python str
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _iter_text_chunks(path: str, chunk_chars: int = _TEXT_STREAM_CHUNK_CHARS):
|
|
208
|
+
"""Yield the file's text in fixed-size chunks so callers never hold more
|
|
209
|
+
than `chunk_chars` characters at once (the fix for f.read() loading a
|
|
210
|
+
multi-GB corpus into one Python str)."""
|
|
211
|
+
with open(path, encoding="utf-8", errors="ignore") as f:
|
|
212
|
+
while True:
|
|
213
|
+
chunk = f.read(chunk_chars)
|
|
214
|
+
if not chunk:
|
|
215
|
+
return
|
|
216
|
+
yield chunk
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _text_token_stream_hf(path: str, vocab_size: int):
|
|
220
|
+
"""Rust-backed BPE (Sennrich et al. 2016) via the `tokenizers` package.
|
|
221
|
+
Trains directly from the file on disk (tokenizers' own file-based
|
|
222
|
+
train() reads it itself, rather than requiring the corpus in memory
|
|
223
|
+
first) and encodes the corpus in fixed-size chunks -- neither step
|
|
224
|
+
materializes the whole file as one Python str or one Python list of
|
|
225
|
+
boxed ints, which is what made the full 2GB TinyStories corpus OOM
|
|
226
|
+
before. Returns a numpy int64 array, not a Python list, for the same
|
|
227
|
+
memory reason on the output side.
|
|
228
|
+
"""
|
|
229
|
+
tok = _HFTokenizer(_HFBPEModel(unk_token=None))
|
|
230
|
+
tok.pre_tokenizer = _HFByteLevel(add_prefix_space=False)
|
|
231
|
+
trainer = _HFBpeTrainer(vocab_size=vocab_size, min_frequency=2, show_progress=False)
|
|
232
|
+
tok.train([path], trainer=trainer) # streams the file itself; no full-corpus string needed
|
|
233
|
+
|
|
234
|
+
chunks = [
|
|
235
|
+
np.asarray(tok.encode(chunk_text).ids, dtype=np.int64)
|
|
236
|
+
for chunk_text in _iter_text_chunks(path)
|
|
237
|
+
]
|
|
238
|
+
ids = np.concatenate(chunks) if chunks else np.empty(0, dtype=np.int64)
|
|
239
|
+
|
|
240
|
+
# ByteLevel BPE's vocabulary is capped at vocab_size by the trainer
|
|
241
|
+
# (alphabet + merges), so ids should already be in [0, vocab_size) --
|
|
242
|
+
# verify instead of silently folding out-of-range ids with modulo,
|
|
243
|
+
# which can collide two genuinely distinct tokens into the same id.
|
|
244
|
+
bad = (ids < 0) | (ids >= vocab_size)
|
|
245
|
+
if bad.any():
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"_text_token_stream_hf: {int(bad.sum())} token id(s) fell outside "
|
|
248
|
+
f"[0, {vocab_size}) -- tokenizer vocab is misconfigured; fix the "
|
|
249
|
+
"trainer's vocab_size instead of folding ids with modulo."
|
|
250
|
+
)
|
|
251
|
+
return ids
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _text_token_stream_hf_chunks(path: str, vocab_size: int):
|
|
255
|
+
"""Same train step as _text_token_stream_hf, but YIELDS each encoded
|
|
256
|
+
chunk instead of concatenating every chunk into one corpus-length array
|
|
257
|
+
-- this is the actual fix for the 2GB-corpus OOM (see module docstring):
|
|
258
|
+
the old function's np.concatenate materialized the whole token stream
|
|
259
|
+
before build_kv_pool_from_tokens ever ran, on top of THAT function's own
|
|
260
|
+
3-4x blowup (now also fixed, see build_kv_pool_from_tokens)."""
|
|
261
|
+
tok = _HFTokenizer(_HFBPEModel(unk_token=None))
|
|
262
|
+
tok.pre_tokenizer = _HFByteLevel(add_prefix_space=False)
|
|
263
|
+
trainer = _HFBpeTrainer(vocab_size=vocab_size, min_frequency=2, show_progress=False)
|
|
264
|
+
tok.train([path], trainer=trainer) # streams the file itself; no full-corpus string needed
|
|
265
|
+
|
|
266
|
+
for chunk_text in _iter_text_chunks(path):
|
|
267
|
+
ids = np.asarray(tok.encode(chunk_text).ids, dtype=np.int64)
|
|
268
|
+
bad = (ids < 0) | (ids >= vocab_size)
|
|
269
|
+
if bad.any():
|
|
270
|
+
raise ValueError(
|
|
271
|
+
f"_text_token_stream_hf_chunks: {int(bad.sum())} token id(s) fell outside "
|
|
272
|
+
f"[0, {vocab_size}) -- tokenizer vocab is misconfigured; fix the "
|
|
273
|
+
"trainer's vocab_size instead of folding ids with modulo."
|
|
274
|
+
)
|
|
275
|
+
yield ids
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def text_token_stream(path: str, vocab_size: int = LABEL_RANGE) -> list[int]:
|
|
279
|
+
if _HF_TOKENIZERS_AVAILABLE:
|
|
280
|
+
with open(path, encoding="utf-8", errors="ignore") as f:
|
|
281
|
+
if not f.read(1):
|
|
282
|
+
raise ValueError(f"{path}: empty text file")
|
|
283
|
+
return _text_token_stream_hf(path, vocab_size)
|
|
284
|
+
# Fallback pure-python tokenizer only: still needs the full text in
|
|
285
|
+
# memory (BPETokenizer.train() itself caps at _BPE_TRAIN_CHARS_CAP).
|
|
286
|
+
with open(path, encoding="utf-8", errors="ignore") as f:
|
|
287
|
+
text = f.read()
|
|
288
|
+
if not text.strip():
|
|
289
|
+
raise ValueError(f"{path}: empty text file")
|
|
290
|
+
tok = BPETokenizer(vocab_size=vocab_size).train(text)
|
|
291
|
+
return tok.encode(text)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def build_text_kv_pool(path, label_range: int = LABEL_RANGE) -> list[tuple[int, int]]:
|
|
295
|
+
"""Streaming AND multi-file: `path` may be a single text file, or a
|
|
296
|
+
directory / glob pattern / '+'-joined list of these (see
|
|
297
|
+
resolve_link_paths) -- e.g. an entire folder of text files, each
|
|
298
|
+
contributing its own bigram-derived facts to one shared pool. Each
|
|
299
|
+
resolved file is trained/encoded independently (the HF path streams +
|
|
300
|
+
trains its own BPE vocabulary per file rather than assuming unrelated
|
|
301
|
+
files share one byte-pair vocabulary) and folded into the same fixed
|
|
302
|
+
count matrix; a chunk's cross-boundary bigram is only ever stitched
|
|
303
|
+
WITHIN one file (see _accumulate_bigram_chunk), never across files.
|
|
304
|
+
Only the `tokenizers`-unavailable fallback still fully materializes
|
|
305
|
+
each file in memory (BPETokenizer.train() itself caps at
|
|
306
|
+
_BPE_TRAIN_CHARS_CAP; see text_token_stream's own docstring)."""
|
|
307
|
+
paths = resolve_link_paths(path)
|
|
308
|
+
matrix = np.zeros(label_range * label_range, dtype=np.int64)
|
|
309
|
+
for p in paths:
|
|
310
|
+
if _HF_TOKENIZERS_AVAILABLE:
|
|
311
|
+
with open(p, encoding="utf-8", errors="ignore") as f:
|
|
312
|
+
if not f.read(1):
|
|
313
|
+
raise ValueError(f"{p}: empty text file")
|
|
314
|
+
prev_last = None
|
|
315
|
+
for chunk in _text_token_stream_hf_chunks(p, label_range):
|
|
316
|
+
prev_last = _accumulate_bigram_chunk(matrix, chunk, label_range, prev_last)
|
|
317
|
+
else:
|
|
318
|
+
tokens = np.asarray(text_token_stream(p, label_range), dtype=np.int64)
|
|
319
|
+
_accumulate_bigram_chunk(matrix, tokens, label_range, None)
|
|
320
|
+
return _pool_from_bigram_matrix(matrix, label_range)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ==========================================================================
|
|
324
|
+
# audio: waveform -> quantized spectrogram-frame token stream
|
|
325
|
+
# ==========================================================================
|
|
326
|
+
# Decoded via the `ffmpeg`/`ffprobe` CLI directly, not torchaudio's I/O
|
|
327
|
+
# backend. torchaudio's legacy info()/load() API has been unstable across
|
|
328
|
+
# releases (e.g. `torchaudio.info` was removed outright in some newer
|
|
329
|
+
# versions -- the exact failure that motivated this switch), and
|
|
330
|
+
# `soundfile` (a common fallback) can't demux mp4/AAC video containers at
|
|
331
|
+
# all, which is exactly the "audio track embedded in a video file" case
|
|
332
|
+
# this pipeline needs. ffmpeg handles any container/codec natively, and
|
|
333
|
+
# Colab ships it preinstalled.
|
|
334
|
+
def _require_ffmpeg() -> None:
|
|
335
|
+
import shutil
|
|
336
|
+
|
|
337
|
+
missing = [exe for exe in ("ffmpeg", "ffprobe") if shutil.which(exe) is None]
|
|
338
|
+
if missing:
|
|
339
|
+
raise RuntimeError(
|
|
340
|
+
f"audio real-data loading requires the `ffmpeg`/`ffprobe` CLI tools on PATH "
|
|
341
|
+
f"(missing: {missing}). Google Colab has both preinstalled; elsewhere "
|
|
342
|
+
"`apt-get install ffmpeg` (Debian/Ubuntu) or `conda install ffmpeg` provides both."
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _ffmpeg_audio_info(path: str) -> int:
|
|
347
|
+
"""-> sample_rate of the first audio stream in `path`, via ffprobe.
|
|
348
|
+
Works on any container ffmpeg can demux (wav/mp3/flac/... as well as a
|
|
349
|
+
video file's embedded audio track, e.g. mp4/mkv)."""
|
|
350
|
+
import json as _json
|
|
351
|
+
import subprocess
|
|
352
|
+
|
|
353
|
+
_require_ffmpeg()
|
|
354
|
+
cmd = [
|
|
355
|
+
"ffprobe",
|
|
356
|
+
"-v",
|
|
357
|
+
"error",
|
|
358
|
+
"-select_streams",
|
|
359
|
+
"a:0",
|
|
360
|
+
"-show_entries",
|
|
361
|
+
"stream=sample_rate",
|
|
362
|
+
"-of",
|
|
363
|
+
"json",
|
|
364
|
+
path,
|
|
365
|
+
]
|
|
366
|
+
try:
|
|
367
|
+
out = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
368
|
+
except subprocess.CalledProcessError as e:
|
|
369
|
+
raise RuntimeError(f"ffprobe failed on {path!r}: {e.stderr.strip()}") from e
|
|
370
|
+
streams = (_json.loads(out.stdout) or {}).get("streams") or []
|
|
371
|
+
if not streams or not streams[0].get("sample_rate"):
|
|
372
|
+
raise ValueError(f"{path}: no audio stream found (or missing sample_rate)")
|
|
373
|
+
return int(streams[0]["sample_rate"])
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _ffmpeg_decode_mono_f32(path: str, sample_rate: int):
|
|
377
|
+
"""Starts an ffmpeg subprocess decoding `path` to mono float32 PCM at
|
|
378
|
+
`sample_rate`, streamed on stdout. `-v error` keeps stderr essentially
|
|
379
|
+
silent in the normal case, which matters for the streaming caller
|
|
380
|
+
below: it only drains stderr after stdout is exhausted, and a chatty
|
|
381
|
+
stderr could otherwise deadlock a long-running decode by filling that
|
|
382
|
+
pipe's OS buffer. Caller must read stdout to EOF and check the return
|
|
383
|
+
code (see _load_waveform / _iter_waveform_chunks)."""
|
|
384
|
+
import subprocess
|
|
385
|
+
|
|
386
|
+
cmd = [
|
|
387
|
+
"ffmpeg",
|
|
388
|
+
"-v",
|
|
389
|
+
"error",
|
|
390
|
+
"-i",
|
|
391
|
+
path,
|
|
392
|
+
"-vn",
|
|
393
|
+
"-ac",
|
|
394
|
+
"1",
|
|
395
|
+
"-ar",
|
|
396
|
+
str(sample_rate),
|
|
397
|
+
"-f",
|
|
398
|
+
"f32le",
|
|
399
|
+
"-",
|
|
400
|
+
]
|
|
401
|
+
return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _load_waveform(path: str):
|
|
405
|
+
"""Decodes the WHOLE file to mono float32 PCM via ffmpeg. Used only by
|
|
406
|
+
audio_token_stream() below, the original whole-file function kept for
|
|
407
|
+
small files / direct use -- build_audio_kv_pool() uses the streaming
|
|
408
|
+
_iter_waveform_chunks() instead and never materializes a whole long
|
|
409
|
+
recording in memory (see that function's docstring)."""
|
|
410
|
+
import numpy as _np
|
|
411
|
+
import torch
|
|
412
|
+
|
|
413
|
+
sr = _ffmpeg_audio_info(path)
|
|
414
|
+
proc = _ffmpeg_decode_mono_f32(path, sr)
|
|
415
|
+
raw, stderr = proc.communicate()
|
|
416
|
+
if proc.returncode != 0:
|
|
417
|
+
raise RuntimeError(
|
|
418
|
+
f"ffmpeg failed decoding {path!r}: {stderr.decode(errors='ignore')[-2000:]}"
|
|
419
|
+
)
|
|
420
|
+
usable = len(raw) - (len(raw) % 4)
|
|
421
|
+
arr = _np.frombuffer(raw[:usable], dtype=_np.float32).copy()
|
|
422
|
+
return torch.from_numpy(arr), sr
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def audio_token_stream(
|
|
426
|
+
path: str, label_range: int = LABEL_RANGE, n_fft: int = 400, hop_length: int = 160
|
|
427
|
+
) -> list[int]:
|
|
428
|
+
"""Each STFT frame's magnitude spectrum is collapsed to a single integer
|
|
429
|
+
id in [0, label_range) via its dominant-frequency-bin index, rescaled
|
|
430
|
+
into the label range. This is a coarse "spectral shape" codebook, not a
|
|
431
|
+
real acoustic-unit tokenizer -- adequate to build a real, non-synthetic
|
|
432
|
+
fact stream out of an actual audio file."""
|
|
433
|
+
import torch
|
|
434
|
+
|
|
435
|
+
waveform, _sr = _load_waveform(path)
|
|
436
|
+
spec = torch.stft(
|
|
437
|
+
waveform,
|
|
438
|
+
n_fft=n_fft,
|
|
439
|
+
hop_length=hop_length,
|
|
440
|
+
window=torch.hann_window(n_fft),
|
|
441
|
+
return_complex=True,
|
|
442
|
+
)
|
|
443
|
+
mag = spec.abs() # (freq_bins, n_frames)
|
|
444
|
+
freq_bins = mag.shape[0]
|
|
445
|
+
dominant_bin = mag.argmax(dim=0) # (n_frames,) in [0, freq_bins)
|
|
446
|
+
scaled = (dominant_bin.float() / max(freq_bins - 1, 1) * (label_range - 1)).round().long()
|
|
447
|
+
return scaled.clamp(0, label_range - 1).tolist()
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
_AUDIO_CHUNK_SECONDS = 30.0 # ~1.3M samples/chunk at 44.1kHz -> a stft matrix of tens of MB,
|
|
451
|
+
# not the multi-GB single-shot matrix a multi-hour file used to need
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _iter_waveform_chunks(path: str, chunk_seconds: float = _AUDIO_CHUNK_SECONDS):
|
|
455
|
+
"""Streams mono float32 PCM straight from an ffmpeg subprocess pipe, in
|
|
456
|
+
chunk_seconds-sized pieces, so a long recording (or a video file's
|
|
457
|
+
audio track) is never materialized whole in memory -- the actual OOM
|
|
458
|
+
fix for multi-hour sources (see module docstring). Reads directly off
|
|
459
|
+
the pipe rather than seeking per-chunk on disk, so this is also a
|
|
460
|
+
single continuous decode, not one ffmpeg invocation per chunk. Yields
|
|
461
|
+
(mono_waveform_chunk: torch.Tensor, sample_rate)."""
|
|
462
|
+
import numpy as _np
|
|
463
|
+
import torch
|
|
464
|
+
|
|
465
|
+
sr = _ffmpeg_audio_info(path)
|
|
466
|
+
chunk_frames = max(1, int(chunk_seconds * sr))
|
|
467
|
+
bytes_per_frame = 4 # f32le, already downmixed to mono by -ac 1
|
|
468
|
+
chunk_bytes = chunk_frames * bytes_per_frame
|
|
469
|
+
proc = _ffmpeg_decode_mono_f32(path, sr)
|
|
470
|
+
try:
|
|
471
|
+
while True:
|
|
472
|
+
raw = proc.stdout.read(chunk_bytes) # BufferedReader.read(n) blocks for n bytes or EOF
|
|
473
|
+
if not raw:
|
|
474
|
+
break
|
|
475
|
+
usable = len(raw) - (len(raw) % bytes_per_frame)
|
|
476
|
+
if usable <= 0:
|
|
477
|
+
break
|
|
478
|
+
arr = _np.frombuffer(raw[:usable], dtype=_np.float32).copy()
|
|
479
|
+
yield torch.from_numpy(arr), sr
|
|
480
|
+
finally:
|
|
481
|
+
proc.stdout.close()
|
|
482
|
+
stderr = proc.stderr.read()
|
|
483
|
+
proc.stderr.close()
|
|
484
|
+
ret = proc.wait()
|
|
485
|
+
if ret != 0:
|
|
486
|
+
raise RuntimeError(
|
|
487
|
+
f"ffmpeg failed decoding {path!r} (exit {ret}): "
|
|
488
|
+
f"{stderr.decode(errors='ignore')[-2000:]}"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def audio_token_stream_chunks(
|
|
493
|
+
path: str,
|
|
494
|
+
label_range: int = LABEL_RANGE,
|
|
495
|
+
n_fft: int = 400,
|
|
496
|
+
hop_length: int = 160,
|
|
497
|
+
chunk_seconds: float = _AUDIO_CHUNK_SECONDS,
|
|
498
|
+
):
|
|
499
|
+
"""Streaming replacement for audio_token_stream(): computes the STFT
|
|
500
|
+
chunk_seconds worth of audio at a time instead of loading the whole
|
|
501
|
+
waveform and running one torch.stft call over it. A small per-chunk
|
|
502
|
+
sample overlap (n_fft - hop_length) is carried over between chunks so
|
|
503
|
+
STFT windows spanning a chunk boundary are still computed, and the
|
|
504
|
+
handful of frames derived purely from that carried-over overlap are
|
|
505
|
+
dropped from every chunk after the first, so tokens are not
|
|
506
|
+
double-counted at chunk boundaries. Uses center=False (unlike the
|
|
507
|
+
whole-file audio_token_stream's default-centered torch.stft) so chunk
|
|
508
|
+
boundaries are unambiguous; this shifts frame alignment by at most one
|
|
509
|
+
window and has no effect on the coarse dominant-frequency-bin codebook
|
|
510
|
+
this pipeline builds. Yields one int64 numpy array of token ids per
|
|
511
|
+
chunk (a very short final chunk may yield nothing)."""
|
|
512
|
+
import torch
|
|
513
|
+
|
|
514
|
+
overlap = max(0, n_fft - hop_length)
|
|
515
|
+
carry = None
|
|
516
|
+
first = True
|
|
517
|
+
for wf, _sr in _iter_waveform_chunks(path, chunk_seconds=chunk_seconds):
|
|
518
|
+
if carry is not None:
|
|
519
|
+
wf = torch.cat([carry, wf])
|
|
520
|
+
if wf.shape[0] < n_fft:
|
|
521
|
+
carry = wf # too short to STFT yet -- fold into the next chunk instead
|
|
522
|
+
continue
|
|
523
|
+
carry = wf[-overlap:].clone() if overlap > 0 else None
|
|
524
|
+
spec = torch.stft(
|
|
525
|
+
wf,
|
|
526
|
+
n_fft=n_fft,
|
|
527
|
+
hop_length=hop_length,
|
|
528
|
+
window=torch.hann_window(n_fft),
|
|
529
|
+
center=False,
|
|
530
|
+
return_complex=True,
|
|
531
|
+
)
|
|
532
|
+
mag = spec.abs() # (freq_bins, n_frames)
|
|
533
|
+
freq_bins = mag.shape[0]
|
|
534
|
+
dominant_bin = mag.argmax(dim=0)
|
|
535
|
+
scaled = (dominant_bin.float() / max(freq_bins - 1, 1) * (label_range - 1)).round().long()
|
|
536
|
+
ids = scaled.clamp(0, label_range - 1)
|
|
537
|
+
if not first:
|
|
538
|
+
n_drop = min(overlap // hop_length, ids.shape[0])
|
|
539
|
+
ids = ids[n_drop:]
|
|
540
|
+
first = False
|
|
541
|
+
if ids.numel():
|
|
542
|
+
yield ids.numpy().astype(np.int64)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def build_audio_kv_pool(
|
|
546
|
+
path,
|
|
547
|
+
label_range: int = LABEL_RANGE,
|
|
548
|
+
n_fft: int = 400,
|
|
549
|
+
hop_length: int = 160,
|
|
550
|
+
chunk_seconds: float = _AUDIO_CHUNK_SECONDS,
|
|
551
|
+
) -> list[tuple[int, int]]:
|
|
552
|
+
"""Multi-file AND streaming counterpart of build_text_kv_pool/
|
|
553
|
+
build_video_kv_pool for audio: `path` may be a single audio file, or a
|
|
554
|
+
directory / glob pattern / '+'-joined list of these (see
|
|
555
|
+
resolve_link_paths) -- e.g. an entire folder of clips with different
|
|
556
|
+
frequency/amplitude profiles. Each resolved file's dominant-frequency
|
|
557
|
+
token stream is computed and folded into the bigram matrix
|
|
558
|
+
chunk_seconds at a time (see audio_token_stream_chunks) rather than
|
|
559
|
+
loading the whole file into one torch.stft call -- this is what makes
|
|
560
|
+
a multi-hour recording (or a long video's audio track) safe to build a
|
|
561
|
+
pool from without OOMing. No bigram is stitched across a FILE boundary
|
|
562
|
+
(prev_last resets per file); it IS correctly stitched across a CHUNK
|
|
563
|
+
boundary within the same file via prev_last."""
|
|
564
|
+
paths = resolve_link_paths(path)
|
|
565
|
+
matrix = np.zeros(label_range * label_range, dtype=np.int64)
|
|
566
|
+
for p in paths:
|
|
567
|
+
prev_last = None
|
|
568
|
+
for chunk in audio_token_stream_chunks(
|
|
569
|
+
p, label_range, n_fft=n_fft, hop_length=hop_length, chunk_seconds=chunk_seconds
|
|
570
|
+
):
|
|
571
|
+
prev_last = _accumulate_bigram_chunk(matrix, chunk, label_range, prev_last)
|
|
572
|
+
return _pool_from_bigram_matrix(matrix, label_range)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# ==========================================================================
|
|
576
|
+
# video: frames -> quantized downsampled-frame token stream
|
|
577
|
+
# ==========================================================================
|
|
578
|
+
def _iter_video_frames(path: str):
|
|
579
|
+
try:
|
|
580
|
+
import cv2
|
|
581
|
+
except ImportError as e:
|
|
582
|
+
raise ImportError(
|
|
583
|
+
"video real-data loading requires `opencv-python` (`pip install opencv-python`). "
|
|
584
|
+
f"Original import error: {e}"
|
|
585
|
+
) from e
|
|
586
|
+
cap = cv2.VideoCapture(path)
|
|
587
|
+
try:
|
|
588
|
+
n = 0
|
|
589
|
+
ok, frame = cap.read()
|
|
590
|
+
while ok:
|
|
591
|
+
yield frame, cv2
|
|
592
|
+
n += 1
|
|
593
|
+
ok, frame = cap.read()
|
|
594
|
+
if n == 0:
|
|
595
|
+
raise ValueError(f"{path}: no frames read (unsupported codec / bad path?)")
|
|
596
|
+
finally:
|
|
597
|
+
cap.release()
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def video_token_stream(path: str, label_range: int = LABEL_RANGE, grid: int = 8) -> list[int]:
|
|
601
|
+
"""Each frame is downsampled to a `grid`x`grid` grayscale thumbnail and
|
|
602
|
+
its mean intensity quantized to [0, label_range) -- a coarse "scene
|
|
603
|
+
brightness/shape" codebook, analogous in spirit to audio_token_stream's
|
|
604
|
+
dominant-frequency-bin codebook. Adequate to build a real, non-synthetic
|
|
605
|
+
fact stream out of an actual video file without a full CV pipeline.
|
|
606
|
+
|
|
607
|
+
Frames are consumed and discarded one at a time (never collected into a
|
|
608
|
+
full-length list first) -- a long/high-resolution source would otherwise
|
|
609
|
+
hold every original full-size frame in RAM before any processing."""
|
|
610
|
+
means = []
|
|
611
|
+
for frame, cv2 in _iter_video_frames(path):
|
|
612
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
613
|
+
small = cv2.resize(gray, (grid, grid), interpolation=cv2.INTER_AREA)
|
|
614
|
+
means.append(float(small.mean()))
|
|
615
|
+
means_arr = np.asarray(means, dtype=np.float32)
|
|
616
|
+
scaled = np.clip(
|
|
617
|
+
(means_arr / 255.0 * (label_range - 1)).round().astype(np.int64), 0, label_range - 1
|
|
618
|
+
)
|
|
619
|
+
return scaled.tolist()
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def build_video_kv_pool(
|
|
623
|
+
path, label_range: int = LABEL_RANGE, grid: int = 8, chunk_frames: int = 200_000
|
|
624
|
+
) -> list[tuple[int, int]]:
|
|
625
|
+
"""Streaming AND multi-file: `path` may be a single video file, or a
|
|
626
|
+
directory / glob pattern / '+'-joined list of these (see
|
|
627
|
+
resolve_link_paths) -- e.g. an entire folder of clips. Quantizes and
|
|
628
|
+
folds frames into the bigram count matrix chunk_frames at a time (never
|
|
629
|
+
building one `means` list for a whole video), and resets the
|
|
630
|
+
within-file stitching state (prev_last) at the start of EACH file, so
|
|
631
|
+
no bigram is stitched across a file boundary."""
|
|
632
|
+
paths = resolve_link_paths(path)
|
|
633
|
+
matrix = np.zeros(label_range * label_range, dtype=np.int64)
|
|
634
|
+
|
|
635
|
+
def _flush(buf):
|
|
636
|
+
arr = np.asarray(buf, dtype=np.float32)
|
|
637
|
+
return np.clip((arr / 255.0 * (label_range - 1)).round(), 0, label_range - 1).astype(
|
|
638
|
+
np.int64
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
for p in paths:
|
|
642
|
+
prev_last = None
|
|
643
|
+
buf = []
|
|
644
|
+
for frame, cv2 in _iter_video_frames(p):
|
|
645
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
646
|
+
small = cv2.resize(gray, (grid, grid), interpolation=cv2.INTER_AREA)
|
|
647
|
+
buf.append(float(small.mean()))
|
|
648
|
+
if len(buf) >= chunk_frames:
|
|
649
|
+
prev_last = _accumulate_bigram_chunk(matrix, _flush(buf), label_range, prev_last)
|
|
650
|
+
buf = []
|
|
651
|
+
if buf:
|
|
652
|
+
prev_last = _accumulate_bigram_chunk(matrix, _flush(buf), label_range, prev_last)
|
|
653
|
+
return _pool_from_bigram_matrix(matrix, label_range)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ==========================================================================
|
|
657
|
+
# shared: token stream -> fact pool -> train/test split
|
|
658
|
+
# ==========================================================================
|
|
659
|
+
def build_kv_pool_from_tokens(token_ids, label_range: int = LABEL_RANGE) -> list[tuple[int, int]]:
|
|
660
|
+
"""Consecutive-pair (bigram) counts -> one (key, most_common_value) fact
|
|
661
|
+
per distinct key seen. Accumulates into a FIXED label_range x label_range
|
|
662
|
+
int64 count matrix (8 MB at label_range=1000, independent of corpus
|
|
663
|
+
length) instead of the old np.unique/lexsort pipeline, which built 3-4
|
|
664
|
+
additional corpus-length arrays on top of `token_ids` itself (the modulo
|
|
665
|
+
copy, the packed-pair array, np.unique's own sort buffer) -- exactly
|
|
666
|
+
what turned a multi-GB token stream into a 10-20x-larger peak RSS and
|
|
667
|
+
OOM'd. `token_ids` is consumed in bounded-size slices, not concatenated
|
|
668
|
+
into another extra array, so even a pre-materialized huge list/array
|
|
669
|
+
here doesn't multiply memory the way the old pipeline did.
|
|
670
|
+
IDs are expected to already be valid -- out-of-range ids raise instead
|
|
671
|
+
of being silently folded in via modulo (a silent fold can collide two
|
|
672
|
+
genuinely distinct tokens into the same id).
|
|
673
|
+
NOTE: ties resolve to whichever value the count matrix's argmax picks
|
|
674
|
+
(lowest value index on a tie) -- harmless, this only seeds a coarse
|
|
675
|
+
synthetic/real fact pool."""
|
|
676
|
+
n = len(token_ids)
|
|
677
|
+
if n < 2:
|
|
678
|
+
raise ValueError(
|
|
679
|
+
"real-data source produced 0 distinct facts "
|
|
680
|
+
"(need at least 4) -- source is too short/uniform to train on."
|
|
681
|
+
)
|
|
682
|
+
matrix = np.zeros(label_range * label_range, dtype=np.int64)
|
|
683
|
+
prev_last = None
|
|
684
|
+
slice_size = 5_000_000
|
|
685
|
+
for start in range(0, n, slice_size):
|
|
686
|
+
chunk = np.asarray(token_ids[start : start + slice_size], dtype=np.int64)
|
|
687
|
+
prev_last = _accumulate_bigram_chunk(matrix, chunk, label_range, prev_last)
|
|
688
|
+
return _pool_from_bigram_matrix(matrix, label_range)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _accumulate_bigram_chunk(
|
|
692
|
+
matrix_flat: np.ndarray, chunk: np.ndarray, label_range: int, prev_last
|
|
693
|
+
):
|
|
694
|
+
"""Folds one chunk's consecutive-pair counts into `matrix_flat` (a flat,
|
|
695
|
+
length label_range**2 int64 array) in place, and returns this chunk's
|
|
696
|
+
last token id so the NEXT chunk's caller can pass it back in as
|
|
697
|
+
`prev_last` (preserves the cross-chunk-boundary bigram that would
|
|
698
|
+
otherwise be lost by chunking). `chunk` must already contain only ids
|
|
699
|
+
in [0, label_range) -- raises rather than silently wrapping out-of-range
|
|
700
|
+
ids with modulo."""
|
|
701
|
+
if chunk.size == 0:
|
|
702
|
+
return prev_last
|
|
703
|
+
bad = (chunk < 0) | (chunk >= label_range)
|
|
704
|
+
if bad.any():
|
|
705
|
+
raise ValueError(
|
|
706
|
+
f"_accumulate_bigram_chunk: {int(bad.sum())} token id(s) fell "
|
|
707
|
+
f"outside [0, {label_range}) -- upstream tokenizer/quantizer is "
|
|
708
|
+
"misconfigured; ids must already be valid."
|
|
709
|
+
)
|
|
710
|
+
if prev_last is not None:
|
|
711
|
+
matrix_flat[prev_last * label_range + int(chunk[0])] += 1
|
|
712
|
+
if chunk.size >= 2:
|
|
713
|
+
idx = chunk[:-1] * label_range + chunk[1:]
|
|
714
|
+
matrix_flat += np.bincount(idx, minlength=label_range * label_range)
|
|
715
|
+
return int(chunk[-1])
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _pool_from_bigram_matrix(matrix_flat: np.ndarray, label_range: int) -> list[tuple[int, int]]:
|
|
719
|
+
matrix = matrix_flat.reshape(label_range, label_range)
|
|
720
|
+
row_sums = matrix.sum(axis=1)
|
|
721
|
+
keys_present = np.nonzero(row_sums)[0]
|
|
722
|
+
if keys_present.size < 4:
|
|
723
|
+
raise ValueError(
|
|
724
|
+
f"real-data source produced only {keys_present.size} distinct facts "
|
|
725
|
+
"(need at least 4) -- source is too short/uniform to train on."
|
|
726
|
+
)
|
|
727
|
+
vals = matrix[keys_present].argmax(axis=1)
|
|
728
|
+
return list(zip(keys_present.tolist(), vals.tolist()))
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def split_train_test_facts(
|
|
732
|
+
pool: list[tuple[int, int]], test_frac: float = 0.1, seed: int = 1234
|
|
733
|
+
) -> tuple[list[tuple[int, int]], list[tuple[int, int]]]:
|
|
734
|
+
"""Disjoint-by-key split so the test pool is genuinely unseen during
|
|
735
|
+
training -- the token-stream analogue of the graph dataset's
|
|
736
|
+
London-Underground-is-a-different-graph OOD guarantee."""
|
|
737
|
+
pool = list(pool)
|
|
738
|
+
rng = random.Random(seed)
|
|
739
|
+
rng.shuffle(pool)
|
|
740
|
+
n_test = max(1, int(len(pool) * test_frac))
|
|
741
|
+
test_pool, train_pool = pool[:n_test], pool[n_test:]
|
|
742
|
+
if len(train_pool) < 4:
|
|
743
|
+
raise ValueError(
|
|
744
|
+
f"real-data source only has {len(train_pool)} training facts after "
|
|
745
|
+
f"reserving {n_test} for the test split -- source is too short."
|
|
746
|
+
)
|
|
747
|
+
return train_pool, test_pool
|