Compress a trained network to 4-bit weights without ever touching the training set — by reconstructing synthetic "Distilled Data" from the batch-norm statistics baked into the model, then using it to find the optimal per-layer bit-width on a Pareto frontier. End to end in under 30 seconds, no fine-tuning.
You trained a beautiful ResNet50 on a hospital's medical-imaging dataset. It works. Now you need it on a phone in the radiology ward: smaller, faster, lower power. The obvious move is quantization — store the weights as 4- or 8-bit integers instead of 32-bit floats. Smaller model, faster integer ALUs, less memory traffic (which is where most of the energy goes).
But there is a catch that nobody tells you about until you try it. Quantizing a 32-bit model down to low precision wrecks its accuracy. The standard fix is quantization-aware fine-tuning: retrain for a few epochs so the weights adjust to the coarse grid. To do that, you need the training data.
Read the constraint precisely, because it shapes everything:
That last point is the loophole the paper exploits. A trained model is not just weights — it carries batch-normalization statistics: the running mean and variance of every layer's activations, computed over the real training data and frozen into the model. That is a fingerprint of the data, sitting right there in the file.
Quantization needs data for two distinct jobs, and ZeroQ has to solve both:
| Job | What it needs data for | What breaks without it |
|---|---|---|
| Clipping range [a,b] | Find the min/max of each layer's activations so you know what range to map onto the integer grid | Guess the range wrong → clip useful values or waste bits on empty range |
| Sensitivity Ωi(k) | Measure how much accuracy each layer loses at each bit-width, to decide which layers to keep high-precision | No way to rank layers → can't do smart mixed-precision |
The naïve escape — feed in random Gaussian noise to estimate ranges and sensitivities — sounds reasonable and fails badly. We will see it fail in Chapter 4. Gaussian noise produces the wrong ordering of which layers are sensitive, which means it picks the wrong layers to compress.
Here is the whole paper in one sentence. The model already contains a compressed description of its training data — in its batch-norm statistics — so we can synthesize fake images that "look like" the real data to the network, and use those fake images to do everything quantization needs.
Every batch-norm layer stored a running mean μi and variance σi2 of its input activations, averaged over the real training set during training. Those numbers say: "when real data flows through me, layer i's activations have this mean and this spread." That is a statistical fingerprint of the data, frozen into the weights.
Notice what is being optimized at each stage, because it is easy to confuse:
The entire procedure for ResNet50 takes under 30 seconds on an 8-GPU box: ~3s to distill data, ~12s to measure sensitivity across all layers and bit-widths, ~14s to run the Pareto search. That is 0.5% of a single training epoch. We build each stage from scratch next.
Before the clever part, let's be precise about what "quantize a tensor to k bits" actually means, because the rest of the paper rests on it. ZeroQ uses asymmetric uniform quantization. "Uniform" means the integer levels are evenly spaced; "asymmetric" means the range need not be centered at zero.
Take any tensor (a weight matrix or a layer's activations). First clip it to a range [a, b]. Then chop that range into 2k−1 equal intervals of width
Each real value v maps to the nearest grid point, stored as an unsigned integer in [0, 2k−1]:
Here v is the original 32-bit value, a, b are the clipping bounds, k is the bit-width (e.g. 4), Δ is the step size (the gap between adjacent representable values), q is the stored integer, and v̂ is the dequantized approximation. The error |v − v̂| is at most Δ/2.
Suppose a weight tensor lives in [a, b] = [−0.8, 1.2] and we quantize to k = 4 bits. Then there are 24−1 = 15 intervals:
A weight v = 0.37 maps to q = round((0.37 + 0.8)/0.1333) = round(8.78) = 9, which dequantizes to v̂ = −0.8 + 9·0.1333 = 0.40. The error is |0.37 − 0.40| = 0.03 — under Δ/2 = 0.067. The whole tensor now needs 4 bits/value instead of 32: an 8× shrink.
Drag the bit-width below and watch a smooth signal get snapped onto the grid. Notice how few levels 2-bit gives you, and how the error explodes — this is exactly why some layers cannot go that low.
The smooth orange curve is the original signal. The teal staircase is its k-bit quantization. Watch the step count and the error band.
This is the heart of the paper. We need synthetic input that the network treats as if it were real training data — without ever seeing real data. The signal we exploit is sitting in every batch-norm layer.
During training, the i-th BN layer watched activations zi (the output of conv layer i) and kept a running average of their mean and variance. After training, the model file contains, for every BN layer:
These were computed from millions of real images. They are a low-dimensional summary of "what the data looks like at every depth of the network."
We start with a batch of random noise xr (drawn from a standard Gaussian) and treat the pixels themselves as the variables to optimize. We push xr through the frozen network and read off the actual mean μ̃ir and std σ̃ir of the activations our noise produces at each layer. Then we minimize the gap between those and the stored values:
Symbol by symbol: xr is the reconstructed (distilled) image batch we are solving for; μ̃ir, σ̃ir are the mean/std of xr's activations measured at layer i right now in the forward pass; μi, σi are the BN stats stored in the model; L is the number of BN layers. The i=0 term pins the input itself to zero mean, unit variance (standard preprocessing).
| Step | Operation | Tensor shapes |
|---|---|---|
| 0 | xr ← Gaussian noise | [N, 3, 224, 224] for ImageNet (N≈32) |
| 1 | Read μi, σi from each BN layer | each [Ci] (per-channel) |
| 2 | Forward xr, gather intermediate activations | activation i: [N, Ci, Hi, Wi] |
| 3 | Compute μ̃ir, σ̃ir over N,H,W dims | each [Ci] |
| 4 | Loss = Eq. 3; backprop to xr; update xr | gradient ∂L/∂xr has xr's shape |
| 5 | Repeat 2–4 for a few hundred iterations | — |
Below: watch noise turn into structure as the statistic-matching loss falls. Each "iteration" nudges the synthetic batch's activation statistics toward the stored BN targets. The loss curve is the ‖μ̃−μ‖2+‖σ̃−σ‖2 sum dropping.
Left: the synthetic batch's per-layer activation stats (bars) converging onto the stored BN targets (dashed). Right: the matching loss over iterations. Press Step to run a few iterations; Distill to run to convergence.
Now we have synthetic data that the network experiences as real. Time for job two: figure out which layers can be aggressively quantized and which must stay high-precision. Not all layers are equal — quantizing a fragile early layer can destroy accuracy, while a robust layer shrugs it off.
The metric is beautifully direct. Quantize only layer i to k bits, leave everything else at full precision, and ask: how much did the model's output change? Measure that change as KL-divergence between the full-precision output distribution and the partially-quantized one, averaged over the Distilled Data:
where M(θ; xj) is the full-precision model's softmax output on Distilled image xj; M(θ̃i(k); xj) is the same model with only layer i quantized to k bits; KL measures how far the quantized output distribution drifted from the original; Ndist is the number of Distilled images. Small Ωi(k) ⇒ layer i barely cares about k-bit quantization ⇒ safe to compress.
The paper's pivotal plot (Figure 2, left): measure each ResNet50 layer's sensitivity to 4-bit quantization three ways — using real training data, using Gaussian noise, and using Distilled Data. If Distilled Data is a good stand-in, its sensitivity curve should track the real-data curve. It does. Gaussian noise does not — for the first few layers its ordering is literally reversed.
Toggle the data source below and watch the per-layer sensitivity curves. Notice how the Distilled curve hugs the real-data curve, while Gaussian diverges — especially in early layers where the ordering flips.
Each curve is Ωi(4) across layers (log scale). Real = ground truth. Toggle which calibration source to overlay.
We can now rank layers. But mixed-precision means choosing a bit-width for every layer, and the choices interact through a budget. For ResNet50 with bit options {2,4,8} over ~50 layers, the search space is 350 ≈ 7.2×1023 configurations. You cannot brute-force that.
ZeroQ assumes layer sensitivities are independent: the cost of quantizing layer i to k bits is Ωi(k), regardless of what the other layers do. This is an approximation (errors can compound), but it makes the total perturbation a simple sum, and that is what makes the search cheap.
The optimization: for a target model size Starget, pick the bit assignment {ki} that minimizes total sensitivity while fitting the budget:
ki is the bit-width chosen for layer i; Ωi(ki) is that layer's sensitivity at that bit-width (from Ch 4); Pi is layer i's parameter count, so Pi·ki is its size in bits; Starget is the size budget. Total cost of building the sensitivity table is only O(m·L) — m bit options times L layers — because each Ωi(k) is measured independently.
Suppose three layers with parameter counts P = [4, 2, 1] (millions), bit options {2,4,8}, and a sensitivity table (lower = more robust):
| Layer | Pi | Ω(2) | Ω(4) | Ω(8) |
|---|---|---|---|---|
| L1 | 4M | 0.90 | 0.20 | 0.02 |
| L2 | 2M | 0.30 | 0.05 | 0.01 |
| L3 | 1M | 0.08 | 0.02 | 0.005 |
Budget Starget = 30 Mbit. All-8-bit costs (4+2+1)·8 = 56 Mbit — too big. The optimizer wants to spend its precious bits where they reduce sensitivity most. The big, sensitive L1 is where high bits pay off; the tiny robust L3 can go to 2-bit nearly free. A good assignment: L1=8 (32 Mbit)… too big. Try L1=4, L2=4, L3=2 → 16+8+2 = 26 Mbit ≤ 30, total Ω = 0.20+0.05+0.08 = 0.33. The interactive sim below runs this exact search live — drag the budget and watch the assignment snap to the lowest-sensitivity point that fits.
Each dot is one mixed-precision configuration (size vs total sensitivity). The lower envelope is the Pareto frontier. Drag the size budget — the solver picks the minimum-sensitivity config under the budget and shows the chosen per-layer bits. Toggle layers to see how sensitivity reshapes the frontier.
Let's assemble the three stages into the end-to-end ZeroQ flow and trace exactly what data crosses each boundary. This is the diagram you should be able to redraw from memory.
Click each box to see what enters and leaves that stage.
| Stage | In | Out | Cost (ResNet50) |
|---|---|---|---|
| Distill Data | Trained model + its BN stats {μi,σi} | Synthetic batch xr [32,3,224,224] | ~3s |
| Sensitivity | xr + model + bit options {2,4,8} | Table Ωi(k), shape [L × m] | ~12s |
| Pareto search | Ω table + Pi + Starget | Bit assignment {ki} | ~14s |
| Quantize | {ki} + xr activation ranges | Mixed-precision integer model | negligible |
The independence assumption in the Pareto step is not exactly true — quantization errors do interact across layers. The appendix relaxes this without an exponential blowup, but the headline method uses the independent approximation and it works because the errors are small enough that the additive surrogate ranks configurations correctly. The lesson: a "wrong" model that ranks options correctly is good enough for a search.
Here is the heart of ZeroQ — Distilled Data generation — as runnable PyTorch. The trick that makes it tiny: hook the BN layers to capture their inputs, compare measured stats to the stored running stats, and backprop into the image.
import torch, torch.nn as nn
def distill_data(model, batch=32, iters=500, lr=0.5, img=(3,224,224), dev='cuda'):
model.eval().to(dev)
# 1) Grab the stored BN targets and register hooks that capture inputs.
bn_targets, captured = [], []
def make_hook(slot):
def hook(mod, inp, out):
captured[slot] = inp[0] # activations entering this BN layer
return hook
for m in model.modules():
if isinstance(m, nn.BatchNorm2d):
bn_targets.append((m.running_mean.detach(),
m.running_var.detach().sqrt())) # (mu_i, sigma_i)
captured = [None] * len(bn_targets)
bns = [m for m in model.modules() if isinstance(m, nn.BatchNorm2d)]
for i, m in enumerate(bns):
m.register_forward_hook(make_hook(i))
# 2) The IMAGE is the variable we optimize; weights stay frozen.
x = torch.randn(batch, *img, device=dev, requires_grad=True)
opt = torch.optim.Adam([x], lr=lr)
for it in range(iters):
opt.zero_grad()
model(x) # forward fills `captured`
loss = 0.0
# input layer: pin to zero-mean / unit-var (standard preprocessing, i=0)
loss += (x.mean()**2) + (x.var() - 1.0)**2
for i, a in enumerate(captured):
mu_t, sig_t = bn_targets[i]
mu_r = a.mean(dim=(0, 2, 3)) # per-channel mean of synthetic activations
sig_r = a.var(dim=(0, 2, 3)).sqrt() # per-channel std
loss += ((mu_r - mu_t)**2).sum() + ((sig_r - sig_t)**2).sum()
loss.backward() # gradient flows into x, not weights
opt.step()
return x.detach() # the Distilled Data
Read the three load-bearing lines. requires_grad=True on x (not the weights) makes the image the optimization variable. The per-channel mean/std over dims (0,2,3) reduces a [N,C,H,W] activation to a [C] statistic, matching the shape of the stored running_mean. And loss.backward() sends gradients all the way back to the pixels.
DataLoader, no labels anywhere. The only "data" is model.running_mean / running_var — numbers already inside the checkpoint. That is what "zero-shot" looks like in code: the training set is reconstructed from the model itself.Sensitivity is then a short loop: for each layer i and bit k, quantize just that layer, run the Distilled Data through both versions, and accumulate KL(full_softmax, quant_softmax). The Pareto step is a dynamic program over the resulting table.
x have requires_grad=True while the model is in eval() with no optimizer over its parameters?Does data-free actually compete with data-hungry methods? The headline numbers say yes — often beating methods that do use real data and fine-tuning.
On MobileNetV2, a notoriously quantization-fragile compact model, ZeroQ at W8A8 lands within 0.12% of the FP32 baseline and beats DFQ (the previous data-free SOTA) by 1.71% — with no data and no fine-tuning. On ResNet18 at the brutal W4A4 setting, ZeroQ hits 69.05%, beating DFC's fine-tuned 68.06%. Read that again: no-data-no-finetune beat with-data-with-finetune.
Top-1 accuracy vs compressed size. Pick a model. ✓/✗ markers note whether each method needed data (D) or fine-tuning (FT).
| Model | Method | No data? | No FT? | W/A bits | Size (MB) | Top-1 |
|---|---|---|---|---|---|---|
| ResNet50 | Baseline (FP32) | — | — | 32/32 | 97.49 | 77.72 |
| ResNet50 | OMSE (no data) | ✓ | ✓ | 4/32 | 12.28 | 70.06 |
| ResNet50 | ZeroQ | ✓ | ✓ | MP/8 | 12.17 | 75.80 |
| MobileNetV2 | DFQ | ✓ | ✓ | 8/8 | 3.34 | 71.20 |
| MobileNetV2 | ZeroQ | ✓ | ✓ | 8/8 | 3.34 | 72.91 |
| ResNet18 | DFC (with FT) | ✓ | ✗ | 4/4 | 5.58 | 68.06 |
| ResNet18 | ZeroQ† | ✓ | ✓ | MP/4 | 5.57 | 69.05 |
ZeroQ† uses percentile clipping (instead of min/max) to set [a,b] — helpful at low precision because it ignores activation outliers. It even extends beyond classification: on COCO object detection with RetinaNet, ZeroQ at W8A8 matched the 36.4 mAP baseline exactly, and DFQ's weight-equalization trick doesn't obviously transfer to detection.
ZeroQ sits at the intersection of three ideas you've probably met separately: post-training quantization, mixed-precision (Hessian/HAWQ-style) bit allocation, and feature-visualization-by-inversion. Its contribution was gluing them so that no real data is needed at any point.
| Symbol / Eq. | Meaning | When you use it |
|---|---|---|
| Δ = (b−a)/(2k−1) | quant step size over clip range [a,b] at k bits | Mapping floats → integers |
| Eq.3: minxr ∑i‖μ̃ir−μi‖2+‖σ̃ir−σi‖2 | Distillation loss — match synthetic activation stats to stored BN stats | Stage 1: generate Distilled Data |
| Eq.2: Ωi(k) = meanj KL(M(θ;xj), M(θ̃i(k);xj)) | Sensitivity of layer i at k bits (output KL on Distilled Data) | Stage 2: rank layers |
| Eq.4: min ∑Ωi(ki) s.t. ∑Piki≤Starget | Pareto bit allocation under size budget | Stage 3: choose per-layer bits |