Spaces:
Sleeping
Sleeping
File size: 12,656 Bytes
d1ecac0 | 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 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 | """``CaptionTokenizer`` — typed wrapper around ``tf.keras.layers.TextVectorization``.
Why a wrapper instead of using the Keras layer directly?
1. **Stable interface for the model.** The model code calls
``tokenizer.encode(captions)`` and ``tokenizer.decode_id(idx)``. The fact
that those happen to delegate to a Keras layer is an implementation
detail. In Phase 5 we may swap the implementation for HuggingFace
``tokenizers`` without rewriting the encoder, decoder, or inference loop.
2. **Persistence.** The notebook saves the *vocabulary list* with pickle, but
loading requires re-instantiating a layer and calling ``set_vocabulary``.
That ceremony belongs inside the wrapper, not at every call site.
3. **A JSON sidecar.** Pickle is fast but opaque, and loading one can run
code. We additionally write a ``vocab.json`` file (a JSON array, one token
per line, UTF-8) so humans and other tools can inspect the vocabulary.
``load`` reads and validates that file first. It reads a pickle only when
there is no JSON file, and refuses any pickle that names a global other
than the two NumPy uses to rebuild string scalars.
The wrapper preserves the notebook's behaviour exactly: ``standardize=None``,
``output_sequence_length`` defaults to ``max_length``, and ``encode`` accepts
either a single string or a list of strings (matching the layer's call form
used in cells 7 and 25).
"""
from __future__ import annotations
import json
import pickle
from collections.abc import Iterable
from pathlib import Path
from typing import Any
VOCAB_PICKLE_FILENAME = "vocab.pkl"
VOCAB_JSON_FILENAME = "vocab.json"
# TextVectorization's padding and OOV tokens. ``get_vocabulary()`` returns them
# at ids 0 and 1, so every saved vocabulary starts with them.
_RESERVED_TOKENS = ("", "[UNK]")
# The only globals a vocabulary pickle may name. The notebook pickles
# ``get_vocabulary()``, whose entries can be ``numpy.str_``, and unpickling
# those needs NumPy's dtype and scalar reconstructor. Neither calls arbitrary
# code: they only build a dtype and a scalar from the pickled values.
_ALLOWED_PICKLE_GLOBALS = frozenset({("numpy", "dtype"), ("numpy.core.multiarray", "scalar")})
class _VocabularyUnpickler(pickle.Unpickler):
"""Unpickler that refuses every global outside ``_ALLOWED_PICKLE_GLOBALS``."""
def find_class(self, module: str, name: str) -> Any:
if (module, name) not in _ALLOWED_PICKLE_GLOBALS:
raise pickle.UnpicklingError(f"refused global {module}.{name}")
return super().find_class(module, name)
def read_vocabulary(directory: str | Path, vocab_size: int) -> list[str]:
"""Read and validate a saved vocabulary list, without importing TensorFlow.
``vocab.json`` wins when both files exist. ``vocab.pkl`` is read only when
there is no JSON file, through ``_VocabularyUnpickler``.
Args:
directory: Directory containing ``vocab.json`` or ``vocab.pkl``.
vocab_size: Maximum vocabulary size the model was built with.
Returns:
The tokens in id order, as plain ``str``.
Raises:
FileNotFoundError: Neither file exists.
ValueError: The file can't be parsed, or its list isn't a vocabulary
this tokenizer could have saved.
"""
directory = Path(directory)
js = directory / VOCAB_JSON_FILENAME
pkl = directory / VOCAB_PICKLE_FILENAME
if js.is_file():
source = js
try:
with js.open(encoding="utf-8") as f:
vocab = json.load(f)
except ValueError as exc: # JSONDecodeError, UnicodeDecodeError
raise ValueError(f"Could not parse tokenizer vocabulary {js}: {exc}") from exc
elif pkl.is_file():
source = pkl
try:
with pkl.open("rb") as f:
vocab = _VocabularyUnpickler(f).load()
except Exception as exc: # malformed pickles raise many exception types
raise ValueError(f"Could not read tokenizer vocabulary {pkl}: {exc}") from exc
else:
raise FileNotFoundError(
f"No tokenizer vocabulary found in {directory!s}. "
f"Expected '{VOCAB_JSON_FILENAME}' (preferred) or "
f"'{VOCAB_PICKLE_FILENAME}'. Train the model with "
"`python -m scripts.train --config configs/base.yaml` to "
"produce the artefacts, or point BACKEND_TOKENIZER_DIR at a "
"directory that contains them."
)
return _validate_vocabulary(vocab, source, vocab_size)
def _validate_vocabulary(vocab: object, source: Path, vocab_size: int) -> list[str]:
"""Return ``vocab`` as plain strings, or raise ``ValueError`` naming ``source``."""
if not isinstance(vocab, list):
raise ValueError(
f"Tokenizer vocabulary {source} must be a list of strings, "
f"got {type(vocab).__name__}."
)
for index, token in enumerate(vocab):
if not isinstance(token, str):
raise ValueError(
f"Tokenizer vocabulary {source} has a non-string entry at id {index}: "
f"{type(token).__name__}."
)
tokens = [str(token) for token in vocab] # numpy.str_ -> str
if tuple(tokens[:2]) != _RESERVED_TOKENS:
raise ValueError(
f"Tokenizer vocabulary {source} must start with the padding and OOV tokens "
f"{list(_RESERVED_TOKENS)!r}, got {tokens[:2]!r}."
)
seen: set[str] = set()
for token in tokens:
if token in seen:
raise ValueError(f"Tokenizer vocabulary {source} repeats the token {token!r}.")
seen.add(token)
if len(tokens) > vocab_size:
raise ValueError(
f"Tokenizer vocabulary {source} has {len(tokens)} entries, "
f"more than vocab_size={vocab_size}."
)
return tokens
class CaptionTokenizer:
"""Wrapper that owns a fitted ``TextVectorization`` layer + lookup tables."""
def __init__(self, vocab_size: int, max_length: int) -> None:
"""Construct an unfit tokenizer.
Args:
vocab_size: Maximum vocabulary size (notebook: ``VOCABULARY_SIZE``).
max_length: Pad/truncate every caption to this many tokens
(notebook: ``MAX_LENGTH``).
"""
self.vocab_size = vocab_size
self.max_length = max_length
self._layer = None
self._idx2word = None
self._word2idx = None
# ----------------------------------------------------------------- fit ----
def fit(self, captions: Iterable[str]) -> None:
"""Adapt the underlying TextVectorization layer to the given captions.
Args:
captions: An iterable of *already preprocessed* captions
(i.e. lower-cased, punctuation-stripped, wrapped in
``[start] ... [end]``). Mirrors notebook cell 7 which calls
``tokenizer.adapt(captions['caption'])`` *after* cell 4 has
applied ``preprocess`` to every row.
"""
import tensorflow as tf
layer = tf.keras.layers.TextVectorization(
max_tokens=self.vocab_size,
standardize=None,
output_sequence_length=self.max_length,
)
layer.adapt(list(captions))
self._layer = layer
self._build_lookups()
# ----------------------------------------------------------- properties ---
@property
def vocabulary(self) -> list[str]:
"""Return the fitted vocabulary list (same order as TextVectorization)."""
layer = self._require_fit()
return list(layer.get_vocabulary())
@property
def vocabulary_size(self) -> int:
"""Number of tokens in the fitted vocabulary."""
return int(self._require_fit().vocabulary_size())
@property
def layer(self):
"""Direct access to the inner Keras layer.
Exposed because the model's ``Embeddings`` layer (notebook cell 19)
needs ``tokenizer.vocabulary_size()`` at construction time. Phase 1b
replaces this with a constructor argument and removes the property.
"""
return self._require_fit()
# -------------------------------------------------------- encode/decode ---
def encode(self, text):
"""Encode ``text`` (str or list[str]) to integer-id tensor.
Mirrors ``tokenizer(text)`` in notebook cells 7 and 25. Single string
returns a 1-D tensor of shape ``[max_length]``; list returns 2-D.
"""
return self._require_fit()(text)
def decode_id(self, idx) -> str:
"""Inverse-lookup a single integer id to its string token.
Mirrors notebook cell 25's
``idx2word(pred_idx).numpy().decode('utf-8')``.
"""
self._require_fit()
# By invariant, _idx2word is set together with _layer in fit/load.
assert self._idx2word is not None
word = self._idx2word(idx)
return word.numpy().decode("utf-8")
def word_to_id(self, word: str) -> int:
"""Look up a single word's integer id, returning 1 (the OOV id) if absent.
Used by beam search to seed beams with the ``[start]`` token without
going through ``TextVectorization``'s padded-string path.
"""
self._require_fit()
assert self._word2idx is not None
return int(self._word2idx(word).numpy())
# ---------------------------------------------------------- persistence ---
def save(self, directory: str | Path) -> None:
"""Save the vocabulary to ``directory/vocab.pkl`` and ``vocab.json``.
The pickle matches notebook cell 9 exactly so old artefacts remain
loadable. The JSON sidecar is human-inspectable.
"""
self._require_fit()
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
vocab = self.vocabulary
with (directory / VOCAB_PICKLE_FILENAME).open("wb") as f:
pickle.dump(vocab, f)
with (directory / VOCAB_JSON_FILENAME).open("w", encoding="utf-8") as f:
json.dump(vocab, f, ensure_ascii=False, indent=2)
@classmethod
def load(
cls,
directory: str | Path,
vocab_size: int,
max_length: int,
) -> CaptionTokenizer:
"""Load a previously saved vocabulary into a new tokenizer.
Args:
directory: Directory containing ``vocab.json`` (preferred) or
``vocab.pkl``.
vocab_size: Maximum vocabulary size — must match the saved vocab.
max_length: Pad/truncate length — must match training-time value.
Returns:
A fitted ``CaptionTokenizer`` ready to ``encode`` and ``decode_id``.
Raises:
FileNotFoundError: Neither vocabulary file exists.
ValueError: The vocabulary is malformed (see ``read_vocabulary``).
"""
vocab = read_vocabulary(directory, vocab_size)
import tensorflow as tf
tok = cls(vocab_size=vocab_size, max_length=max_length)
layer = tf.keras.layers.TextVectorization(
max_tokens=vocab_size,
standardize=None,
output_sequence_length=max_length,
)
layer.set_vocabulary(vocab)
tok._layer = layer
tok._build_lookups()
return tok
# -------------------------------------------------------------- internal --
def _build_lookups(self) -> None:
"""Construct ``StringLookup`` (idx → word) for inference decoding.
Called only from ``fit()`` and ``load()``, *after* ``self._layer`` has
been assigned, so the assertion below is a defensive no-op for mypy.
"""
import tensorflow as tf
assert self._layer is not None
vocab = self._layer.get_vocabulary()
self._word2idx = tf.keras.layers.StringLookup(mask_token="", vocabulary=vocab)
self._idx2word = tf.keras.layers.StringLookup(mask_token="", vocabulary=vocab, invert=True)
def _require_fit(self):
"""Validate that the tokenizer has been fitted; return the inner layer.
Returning the layer (rather than only raising on the unfit state)
gives callers a non-``None``-typed local for the rest of their body —
which is what mypy needs to prove ``layer.get_vocabulary()`` etc.
are valid calls. Costs one attribute lookup at runtime.
"""
if self._layer is None:
raise RuntimeError(
"CaptionTokenizer not fitted. Call `.fit(captions)` or "
"`.load(directory, ...)` first."
)
return self._layer
|