EugeneBoondock's picture
Sponge online learners and the compression-scored search that found them
b999890 verified
Raw History Blame Contribute Delete
4.25 kB
"""Decision mode: turn a next-byte learner into a classifier that outputs probabilities.
One learner per label is fed that label's examples. A new input is judged by how many
bits each label's learner needs to encode it: fewer bits means "this looks like mine".
Learning an example is just more counting, so there is no retraining step.
The learner code runs inside this process, so only use learner files you trust
(the shipped seeds, or a searched candidate you have read).
"""
from __future__ import annotations
import copy
import math
from pathlib import Path
from typing import Callable
from compsearch.runner import byte_cost_bits
from compsearch.safety import check_source
SEPARATOR = b"\n\x00\n" # marks the boundary between examples
TEMPERATURES = (1, 2, 3, 5, 8, 12, 20, 30, 50, 80)
def load_learner_factory(path: str | Path) -> Callable[[], object]:
"""Return a zero-argument function that builds a fresh Learner from a file."""
source = Path(path).read_text(encoding="utf-8")
problem = check_source(source)
if problem:
raise ValueError(f"{path}: {problem}")
namespace: dict = {"__name__": "learner"}
exec(compile(source, str(path), "exec"), namespace) # noqa: S102 - checked, and trusted by the caller
return namespace["Learner"]
def encode_cost_bits(learner, text: bytes) -> float:
"""Bits the learner needs for `text`. Advances the learner's state."""
bits = 0.0
for byte in text:
bits += byte_cost_bits(learner.predict(), byte)
learner.update(byte)
return bits
class Classifier:
def __init__(self, learner_factory: Callable[[], object], temperature: float = 1.0):
if temperature <= 0:
raise ValueError("temperature must be positive")
self.factory = learner_factory
self.temperature = temperature
self.learners: dict[str, object] = {}
self.counts: dict[str, int] = {}
def learn(self, label: str, text: bytes) -> None:
"""Absorb one labelled example. Can be called at any time, in any order."""
if not text:
raise ValueError("cannot learn from empty text")
learner = self.learners.setdefault(label, self.factory())
for byte in text + SEPARATOR:
learner.predict()
learner.update(byte)
self.counts[label] = self.counts.get(label, 0) + 1
def costs(self, text: bytes) -> dict[str, float]:
"""Bits each label's learner needs for the text. Leaves the learners unchanged."""
if not self.learners:
raise ValueError("no examples learned yet")
if not text:
raise ValueError("cannot judge empty text")
return {label: encode_cost_bits(copy.deepcopy(learner), text)
for label, learner in self.learners.items()}
def judge(self, text: bytes) -> dict[str, float]:
"""Probability of each label for the text."""
return probabilities(self.costs(text), self.temperature)
def surprise(self, text: bytes) -> float:
"""Bits per byte under the best-fitting label; high means 'unlike anything seen'."""
return min(self.costs(text).values()) / len(text)
def calibrate(self, examples: list[tuple[str, bytes]]) -> float:
"""Pick the temperature that makes probabilities honest on held-out examples."""
if not examples:
raise ValueError("need at least one example to calibrate")
scored = [(label, self.costs(text)) for label, text in examples]
self.temperature = min(TEMPERATURES, key=lambda t: log_loss(scored, t))
return self.temperature
def probabilities(costs: dict[str, float], temperature: float) -> dict[str, float]:
best = min(costs.values())
weights = {label: 2.0 ** (-(cost - best) / temperature) for label, cost in costs.items()}
total = sum(weights.values())
return {label: weight / total for label, weight in weights.items()}
def log_loss(scored: list[tuple[str, dict[str, float]]], temperature: float) -> float:
"""Average bits of surprise at the true label; lower means better-calibrated."""
return sum(-math.log2(max(probabilities(costs, temperature)[label], 1e-12))
for label, costs in scored) / len(scored)