Download hdc/net.py from AEUPH/hdcstorage: direct link, hf CLI and curl.
- Browser
- Download file 13.2 kB
-
https://huggingface.co/AEUPH/hdcstorage/resolve/main/hdc/net.py
- Command line
-
hf download hf://AEUPH/hdcstorage/hdc/net.py
-
curl -L -o net.py https://huggingface.co/AEUPH/hdcstorage/resolve/main/hdc/net.py
13.2 kB
| """BitNetBase: 1-bit and ternary inference on hyperdimensional primitives. | |
| The observation this module is built on is that a 1-bit dot product and an | |
| HDC similarity are *the same operation*. For sign vectors ``a, b`` in | |
| {-1, +1}^n, packed one bit per weight with +1 -> 1 and -1 -> 0: | |
| disagree = popcount(a_bits ^ b_bits) # XOR is HDC's bind | |
| dot = n - 2 * disagree # popcount is HDC's sim | |
| so ``sim(a, b) = dot(a, b) / n``. A BitNet layer's entire forward pass is | |
| therefore XOR-and-bitcount over packed registers — no multiply, no float | |
| accumulate — and :func:`dot_1bit_packed` verifies that identity against the | |
| plain integer dot at runtime rather than asserting it in prose. | |
| Two things live here: | |
| ``BitLinear`` a ternary {-1, 0, +1} linear layer in the spirit of BitNet | |
| b1.58: per-tensor absmean scaling with a dead zone, so small | |
| weights collapse to 0 instead of costing compute | |
| ``PrototypeNet`` a 1-bit classifier whose weights *are* hypervectors in an | |
| :class:`~hdc.memory.HDCStorage` cell — training is bundling, | |
| inference is a similarity argmax, and nothing is ever a | |
| float array | |
| Standard library only; vectors are Python ints, so the kernels below are | |
| bitwise ops over whole machine words. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import random | |
| from typing import Sequence | |
| from .hypervec import DEFAULT_DIM, bundle, hvc, mask, popcount, sim | |
| from .memory import HDCStorage | |
| __all__ = [ | |
| "pack_signs", | |
| "unpack_signs", | |
| "dot_1bit", | |
| "dot_1bit_packed", | |
| "ternarize", | |
| "quantize_absmax", | |
| "ternary_dot", | |
| "ternary_matvec", | |
| "BitLinear", | |
| "PrototypeNet", | |
| "selfcheck", | |
| ] | |
| # -------------------------------------------------------------------------- | |
| # 1-bit kernels | |
| # -------------------------------------------------------------------------- | |
| def pack_signs(vec: Sequence[int]) -> int: | |
| """Pack a {-1, +1} sequence into a bitmask, +1 -> 1, -1 -> 0. | |
| Bit ``i`` of the result is weight ``i``. Any non-positive entry counts as | |
| -1, which keeps zero weights meaningful: a packed zero contributes -1 to | |
| the dot product, so ternary matrices are packed with an explicit sparse | |
| form (see :func:`ternary_dot`) rather than through this function. | |
| """ | |
| out = 0 | |
| for i, v in enumerate(vec): | |
| if v > 0: | |
| out |= 1 << i | |
| return out | |
| def unpack_signs(bits: int, n: int) -> list[int]: | |
| """Inverse of :func:`pack_signs`: ``n`` weights as +1/-1.""" | |
| return [1 if (bits >> i) & 1 else -1 for i in range(n)] | |
| def dot_1bit(a: Sequence[int], b: Sequence[int]) -> int: | |
| """Plain integer dot product of two +/-1 sequences (the reference form).""" | |
| if len(a) != len(b): | |
| raise ValueError("length mismatch: %d vs %d" % (len(a), len(b))) | |
| return sum(x * y for x, y in zip(a, b)) | |
| def dot_1bit_packed(a_bits: int, b_bits: int, n: int) -> int: | |
| """Bitwise dot product of two packed sign vectors. | |
| ``n - 2 * popcount(a ^ b)`` — one XOR and one bit-count for the whole | |
| vector, independent of ``n`` in loop terms. This is the identity the | |
| module exists to demonstrate: bind, then count disagreement. | |
| """ | |
| disagree = popcount((a_bits ^ b_bits) & mask(n)) | |
| return n - 2 * disagree | |
| # -------------------------------------------------------------------------- | |
| # ternary quantization | |
| # -------------------------------------------------------------------------- | |
| def ternarize(weights: Sequence[float], ratio: float = 0.7) -> list[int]: | |
| """Quantize to {-1, 0, +1} with a per-tensor absmean scale. | |
| ``scale = mean(|w|)``, and weights inside ``ratio * scale`` of zero are | |
| mapped to 0. With ``ratio = 1.0`` this is nearest-rounding of BitNet | |
| b1.58's absmean scheme; lower ratios keep more weights non-zero (denser, | |
| slightly more accurate), higher ratios sparsify the layer. | |
| """ | |
| items = [float(w) for w in weights] | |
| if not items: | |
| return [] | |
| scale = sum(abs(w) for w in items) / len(items) | |
| if scale == 0.0: | |
| return [0] * len(items) | |
| dead = ratio * scale | |
| out: list[int] = [] | |
| for w in items: | |
| if w > dead: | |
| out.append(1) | |
| elif w < -dead: | |
| out.append(-1) | |
| else: | |
| out.append(0) | |
| return out | |
| def quantize_absmax(x: Sequence[float], bits: int = 8) -> tuple[list[int], float]: | |
| """Symmetric activation quantization to signed integers. | |
| Returns ``(integers, scale)`` such that ``x ~= integer * scale``. ``scale`` | |
| is 1.0 for an all-zero input, which keeps the call total instead of | |
| dividing by zero. | |
| """ | |
| if not 1 <= bits <= 16: | |
| raise ValueError("bits must be in 1..16") | |
| qmax = (1 << (bits - 1)) - 1 | |
| peak = max((abs(float(v)) for v in x), default=0.0) | |
| if peak == 0.0: | |
| return [0] * len(x), 1.0 | |
| scale = peak / qmax | |
| out = [] | |
| for v in x: | |
| q = round(float(v) / scale) | |
| out.append(max(-qmax - 1, min(qmax, q))) | |
| return out, scale | |
| def ternary_dot(w: Sequence[int], x: Sequence[int]) -> int: | |
| """Integer dot product of a ternary weight row with an integer activation.""" | |
| if len(w) != len(x): | |
| raise ValueError("length mismatch: %d vs %d" % (len(w), len(x))) | |
| total = 0 | |
| for wi, xi in zip(w, x): | |
| if wi == 1: | |
| total += xi | |
| elif wi == -1: | |
| total -= xi | |
| # wi == 0 contributes nothing: the point of sparsity | |
| return total | |
| def ternary_matvec(matrix: Sequence[Sequence[int]], x: Sequence[int]) -> list[int]: | |
| """Apply a ternary matrix to an integer vector.""" | |
| return [ternary_dot(row, x) for row in matrix] | |
| # -------------------------------------------------------------------------- | |
| # a ternary linear layer | |
| # -------------------------------------------------------------------------- | |
| class BitLinear: | |
| """A ternary {-1, 0, +1} linear layer with additive-only accumulation. | |
| Weights are drawn once from a seeded normal distribution and ternarized at | |
| construction, so two layers with the same shape and seed are identical. | |
| ``forward`` accumulates with adds and subtracts only: a weight of -1 is a | |
| subtraction and a weight of 0 is skipped, which is what makes 1-bit | |
| inference cheap on hardware that has no multiplier. | |
| :meth:`scale` exposes the absmean scale discarded at ternarization time, so | |
| callers that want a dequantized output can multiply it back in; ``forward`` | |
| itself returns the raw integer accumulators. | |
| """ | |
| def __init__(self, in_features: int, out_features: int, seed: int = 0): | |
| if in_features <= 0 or out_features <= 0: | |
| raise ValueError("layer dimensions must be positive") | |
| self.in_features = in_features | |
| self.out_features = out_features | |
| self.seed = seed | |
| rng = random.Random(seed) | |
| raw = [[rng.gauss(0.0, 1.0) for _ in range(in_features)] for _ in range(out_features)] | |
| self.ternary_weights: list[list[int]] = [ternarize(row) for row in raw] | |
| self._scales = [ | |
| sum(abs(w) for w in row) / in_features for row in raw | |
| ] | |
| def scale(self, row: int) -> float: | |
| """Absmean scale of output row ``row`` (multiply an output by this).""" | |
| return self._scales[row] | |
| def forward(self, x: Sequence[int]) -> list[int]: | |
| """Integer accumulators for one input vector.""" | |
| if len(x) != self.in_features: | |
| raise ValueError( | |
| "expected %d inputs, got %d" % (self.in_features, len(x)) | |
| ) | |
| return ternary_matvec(self.ternary_weights, x) | |
| def density(self) -> float: | |
| """Fraction of non-zero weights (measured, not assumed).""" | |
| total = self.in_features * self.out_features | |
| live = sum(1 for row in self.ternary_weights for w in row if w != 0) | |
| return live / total if total else 0.0 | |
| # -------------------------------------------------------------------------- | |
| # 1-bit classifier whose weights live in HDC storage | |
| # -------------------------------------------------------------------------- | |
| class PrototypeNet: | |
| """Nearest-prototype classifier with class weights stored as hypervectors. | |
| Training a class is bundling its samples into a single majority vector, so | |
| the class weight is a superposition — the HDC centroid. Inference compares | |
| a probe against each class cell by Hamming similarity and takes the argmax. | |
| The weights are ordinary :class:`HDCStorage` cells, so they persist with | |
| ``HDCStorage.save`` and are addressable by content like everything else. | |
| """ | |
| def __init__( | |
| self, | |
| storage: HDCStorage | None = None, | |
| d: int = DEFAULT_DIM, | |
| prefix: str = "proto/", | |
| ): | |
| self.d = d | |
| self.prefix = prefix | |
| self.storage = storage if storage is not None else HDCStorage(d=d) | |
| self._samples: dict[str, list[int]] = {} | |
| def _cell(self, label: str) -> str: | |
| return self.prefix + label | |
| def fit(self, label: str, sample: int) -> None: | |
| """Add one hypervector sample to a class and rewrite its weight cell. | |
| The class weight is recomputed as the majority bundle of every sample | |
| seen so far rather than folded in pairwise: pairwise bundling of two | |
| vectors is AND-like and would forget the first sample quickly. | |
| """ | |
| samples = self._samples.setdefault(label, []) | |
| samples.append(sample & mask(self.d)) | |
| weight = bundle(samples, self.d) | |
| self.storage.write(self._cell(label), weight, symbol=True) | |
| def classes(self) -> list[str]: | |
| """Class labels, in the order they were first fitted.""" | |
| return list(self._samples) | |
| def weights(self) -> list[tuple[str, int]]: | |
| """``(label, weight vector)`` pairs as currently stored.""" | |
| return [(label, self.storage.read(self._cell(label))) for label in self._samples] | |
| def predict(self, sample: int) -> tuple[str, float]: | |
| """Best matching class and its similarity; ``('', 0.0)`` when untrained.""" | |
| best_label = "" | |
| best = -2.0 | |
| for label in self._samples: | |
| s = sim(sample, self.storage.read(self._cell(label)), self.d) | |
| if s > best: | |
| best, best_label = s, label | |
| if not best_label: | |
| return "", 0.0 | |
| return best_label, best | |
| def accuracy(self, pairs: Sequence[tuple[str, int]]) -> float: | |
| """Fraction of ``(expected_label, sample)`` pairs classified correctly.""" | |
| if not pairs: | |
| return 0.0 | |
| hits = 0 | |
| for label, sample in pairs: | |
| got, _ = self.predict(sample) | |
| if got == label: | |
| hits += 1 | |
| return hits / len(pairs) | |
| # -------------------------------------------------------------------------- | |
| # self-verification | |
| # -------------------------------------------------------------------------- | |
| def _noisy(base: int, rate: float, rng: random.Random, d: int) -> int: | |
| """Flip each bit of ``base`` with probability ``rate``.""" | |
| v = base | |
| for i in range(d): | |
| if rng.random() < rate: | |
| v ^= 1 << i | |
| return v & mask(d) | |
| def selfcheck() -> dict: | |
| """Exercise the kernels and return measured values (nothing hardcoded). | |
| Covers the packed/plain dot-product equivalence, the sparsity a ternary | |
| layer actually reaches, and end-to-end prototype classification accuracy | |
| under 2% bit noise. | |
| """ | |
| rng = random.Random(20240501) | |
| # 1. the identity: packed XOR dot == integer dot | |
| trials = 25 | |
| mismatches = 0 | |
| for _ in range(trials): | |
| n = rng.choice((16, 64, 256, 1000)) | |
| signs_a = [rng.choice((-1, 1)) for _ in range(n)] | |
| signs_b = [rng.choice((-1, 1)) for _ in range(n)] | |
| plain = dot_1bit(signs_a, signs_b) | |
| packed = dot_1bit_packed(pack_signs(signs_a), pack_signs(signs_b), n) | |
| if plain != packed: | |
| mismatches += 1 | |
| xor_matches_dot = mismatches == 0 | |
| # 2. measured sparsity of a ternarized gaussian layer | |
| layer = BitLinear(128, 32, seed=7) | |
| zeros = sum(1 for row in layer.ternary_weights for w in row if w == 0) | |
| ternary_sparsity = zeros / (128 * 32) | |
| # 3. prototype accuracy: 4 classes, 6 training samples, 8 noisy test samples | |
| d = 2000 | |
| net = PrototypeNet(d=d) | |
| test_pairs: list[tuple[str, int]] = [] | |
| for c in range(4): | |
| base = hvc("selfcheck/class/%d" % c, d) | |
| for _ in range(6): | |
| net.fit("class%d" % c, _noisy(base, 0.02, rng, d)) | |
| for _ in range(2): | |
| test_pairs.append(("class%d" % c, _noisy(base, 0.02, rng, d))) | |
| return { | |
| "xor_matches_dot": xor_matches_dot, | |
| "xor_trials": trials, | |
| "ternary_sparsity": round(ternary_sparsity, 4), | |
| "proto_density": round(layer.density(), 4), | |
| "prototype_accuracy": round(net.accuracy(test_pairs), 4), | |
| "prototype_classes": len(net.classes()), | |
| "prototype_dim": d, | |
| "d": DEFAULT_DIM, | |
| } | |
| if __name__ == "__main__": # pragma: no cover - manual entry point | |
| print(json.dumps(selfcheck(), indent=2, sort_keys=True)) | |