open-standards/wai/tools/wai_int_synth_qat.py
Transaction Science 08b1568291 wai: int_synth's weights are quantised after float training, and two engine paths moved
SPEC §5 and determinism-tiers §5 said `wai.neural.int_synth` decodes "QAT
weights". No committed model was trained that way. The trained corpus vector was
made by `tools/wai_int_synth_real.py`: float training, then post-training
quantisation with each layer's shift derived from its measured activation range,
the recipe `tools/wai_int_synth_train.py` documents. The synthetic vectors' weights
are generated from a seed. Both texts, the capability constant's comment, the
module doc and a test comment now say that.

`tools/wai_int_synth_qat.py` said it "closes the one open gap" and tunes "the
weights AND the shifts"; it trains the weights against one fixed shift and made no
committed model, and it now says so. `tools/wai_int_synth_export.py` told users to
train with QAT upstream; it now names the working path.

Stale paths: the int_synth decoder is `int_synth.rs` at the crate root, not
`neural/int_synth.rs` (SPEC §5, the export tool, the capability comment); the
quantum-ops engine is `wai-quantum/src/quantum_ops.rs`, and the quantum-sim
receipt's feature is `quantum_receipt` of `wai-quantum`, which `wai-rs` forwards,
not `["quantum", "world"]`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:55:53 -04:00

127 lines
5.8 KiB
Python

"""An EXPERIMENTAL quantization-aware training (QAT) attempt for the
`wai.neural.int_synth` capability (decoder: `wai-rs/src/int_synth.rs`). No
committed model was made with it.
`int_synth` runs an all-integer transposed-conv stack with a per-layer
requantize `>> shift`. The decode is bit-exact on every machine regardless of
weights. This tool trains the weights with straight-through fake-quant (forward =
quantized integers, backward = float gradients) against ONE fixed shift for every
layer; it does not learn the shifts. With a fixed shift, small activations can
collapse to 0 (see the comment in `IntSynth.forward`). The working path is
`tools/wai_int_synth_train.py`: train in
float, then quantise after training with each layer's shift derived from its
measured activation range. The trained corpus vector was made that way
(`tools/wai_int_synth_real.py`).
Inputs: paired (latent, target_image) tensors — e.g. dump `(y_hat, x)` from a
trained float codec on a dataset, or co-train with an analysis transform. This
tool is the decoder-side QAT + export; the latents come from upstream.
Output: `int_synth.json` (== the Rust spec).
Run (needs torch):
python tools/wai_int_synth_qat.py --data pairs.pt --epochs 20 \
--out wai-web/demo/models/int_synth/int_synth.json
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import torch
import torch.nn as nn
import torch.nn.functional as F
def fake_quant_weight(w: torch.Tensor, bits: int) -> torch.Tensor:
"""Straight-through symmetric per-tensor weight fake-quant → integer grid."""
qmax = (1 << (bits - 1)) - 1
scale = w.detach().abs().max().clamp_min(1e-8) / qmax
wq = torch.clamp(torch.round(w / scale), -qmax - 1, qmax) * scale
return w + (wq - w).detach(), scale # STE: forward quantized, backward identity
class IntSynth(nn.Module):
"""A transposed-conv synthesis whose forward emulates the integer decode:
fake-quant weights, integer-style requantize via the per-layer `shift`."""
def __init__(self, chans, kernel=5, stride=2, w_bits=8, shift=8):
super().__init__()
self.w_bits, self.shift = w_bits, shift
self.convs = nn.ModuleList()
for i in range(len(chans) - 1):
pad = (kernel - stride + 1) // 2
self.convs.append(nn.ConvTranspose2d(chans[i], chans[i + 1], kernel,
stride=stride, padding=pad, output_padding=stride - 1))
def forward(self, y):
x = y
for i, conv in enumerate(self.convs):
wq, _ = fake_quant_weight(conv.weight, self.w_bits)
x = F.conv_transpose2d(x, wq, conv.bias, conv.stride, conv.padding, conv.output_padding)
# Emulate the integer >> shift requantize. NB: plain torch.floor has
# ZERO gradient — it silently freezes training. Use a straight-through
# estimator so gradients flow as for the float division. Even so, a
# FIXED shift can collapse small activations to 0; for a model that
# actually reconstructs use tools/wai_int_synth_train.py (float train
# + per-layer activation-calibrated shifts) instead of pure QAT.
d = x / (1 << self.shift)
x = d + (torch.floor(d) - d).detach()
if i < len(self.convs) - 1:
x = torch.relu(x)
return x
def export_spec(self) -> dict:
layers = []
for i, conv in enumerate(self.convs):
qmax = (1 << (self.w_bits - 1)) - 1
w = conv.weight.detach()
scale = float(w.abs().max()) / qmax if float(w.abs().max()) > 0 else 1.0
w_q = torch.clamp(torch.round(w / scale), -qmax - 1, qmax).to(torch.int64)
b = conv.bias.detach() if conv.bias is not None else torch.zeros(conv.out_channels)
b_q = torch.round(b / scale * (1 << self.shift)).to(torch.int64)
pad = conv.padding[0]
layers.append({
"in_ch": conv.in_channels, "out_ch": conv.out_channels,
"k": conv.kernel_size[0], "stride": conv.stride[0],
"pad": pad, "out_pad": conv.output_padding[0],
"weight": w_q.reshape(-1).tolist(), "bias": b_q.reshape(-1).tolist(),
"shift": self.shift, "relu": i < len(self.convs) - 1,
})
return {"layers": layers}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--data", required=True, help="torch .pt with tensors 'y' (latents) and 'x' (target images, [0,1]).")
ap.add_argument("--chans", type=int, nargs="+", default=[192, 128, 64, 32, 3],
help="channel widths: latent → … → 3 (RGB).")
ap.add_argument("--epochs", type=int, default=20)
ap.add_argument("--lr", type=float, default=1e-4)
ap.add_argument("--shift", type=int, default=8)
ap.add_argument("--out", required=True)
args = ap.parse_args()
blob = torch.load(args.data, map_location="cpu", weights_only=False)
y, x = blob["y"].float(), blob["x"].float()
net = IntSynth(args.chans, shift=args.shift).train()
opt = torch.optim.Adam(net.parameters(), lr=args.lr)
for ep in range(args.epochs):
opt.zero_grad()
recon = net(y) / 255.0 # integer domain → [0,1]-ish
loss = F.mse_loss(recon.clamp(0, 1), x)
loss.backward()
opt.step()
if ep % 5 == 0 or ep == args.epochs - 1:
print(f"epoch {ep:3d} mse {loss.item():.5f}")
out = Path(args.out); out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(net.eval().export_spec(), separators=(",", ":")))
print(f"wrote an experimental QAT integer synthesis → {out} (shift={args.shift}); "
f"decode is bit-exact on any machine by construction, quality is not checked.")
return 0
if __name__ == "__main__":
raise SystemExit(main())