PerChannelMinMaxObserver
ObserverBasePerChannelMinMaxObserver(ch_axis: int = 0, qscheme: QScheme = per_channel_symmetric, qdtype: QDtype = qint8, eps: float = 1e-08)Per-channel observer tracking a running min / max along ch_axis.
The standard weight observer. Rather than one range for the whole tensor, it
keeps an independent [min, max] for every slice along ch_axis — reducing over
all other axes — so calculate_qparams returns a length-C vector of
(scale, zero_point), one per output channel. Because different output neurons /
filters routinely span wildly different dynamic ranges, a single per-tensor scale is
dominated by the widest channel and quantizes the rest coarsely; a per-channel scale
tracks each one tightly, which is why per-channel symmetric qint8 is the standard
accuracy-preserving choice for convolution and linear weights.
When to pick it. Use it for weights — it is the weight observer in both
lucid.quantization.get_default_qconfig (static PTQ) and
lucid.quantization.get_default_qat_qconfig (QAT, wrapped in
FakeQuantize). It is not used for activations, whose channel layout varies
per op and which are cheaper to quantize per-tensor. For a smoothed per-channel range
over noisy weight calibration, use MovingAveragePerChannelMinMaxObserver.
Writing the input with ch_axis moved to the front and the rest flattened,
denotes the values of channel ; each channel keeps its own
cumulative range:
then the (default symmetric, signed) reduction pins zero_point to 0 and scales
each channel by its own peak magnitude:
(an affine qscheme instead yields per-channel (s_c, z_c) via the base-class
affine map).
Parameters
ch_axisint= 0(out, in, kh, kw) and linear weights (out, in).zero_point per
channel.qint8 gives the signed grid [-128, 127].epsfloat= 1e-8scale.Attributes
Notes
- Scalar seed → per-channel vector. The buffers start as scalar
±inf; the firstforwardbroadcasts that seed against the length-Cbatch range, somin_val/max_valacquire shape(C,)on the first observation. This shape change rides on the buffer re-registration contract (plain assignment would drop the buffer from_buffers). - Cumulative per channel — like
MinMaxObserver, the ranges only widen, so the observer is outlier-sensitive; smooth with the moving-average variant. - Symmetric vs affine. The default per-channel-symmetric scheme fixes
zero_point = 0(signed weights map 0 → 0 exactly); an affine scheme gives each channel a free zero-point. - Reduction axes. Every axis except
ch_axisis reduced, so the number of qparams equalsx.shape[ch_axis]. - Weight observer of both default QConfigs
(
lucid.quantization.get_default_qconfigandlucid.quantization.get_default_qat_qconfig).
Examples
>>> import lucid
>>> import lucid.quantization as Q
>>> obs = Q.PerChannelMinMaxObserver(ch_axis=0)
>>> _ = obs(lucid.randn(16, 8)) # 16 output channels
>>> scale, zero_point = obs.calculate_qparams()
>>> scale.shape # one scale per output channel
(16,)
>>> bool((zero_point == 0).all().item()) # symmetric ⇒ zero_point pinned to 0
True
Per-channel is far tighter than per-tensor when channels differ in range — here one
row is ~1000x larger, yet each row keeps its own scale:
>>> import lucid
>>> w = lucid.stack([lucid.ones(4) * 0.001, lucid.ones(4) * 1.0])
>>> obs = Q.PerChannelMinMaxObserver(ch_axis=0)
>>> _ = obs(w)
>>> s, _ = obs.calculate_qparams()
>>> bool(s[0].item() < s[1].item()) # small-range row ⇒ smaller step
TrueSee Also
- lucid.quantization.MinMaxObserver—Per-tensor counterpart.
- lucid.quantization.MovingAveragePerChannelMinMaxObserver—Per-channel EMA variant.
- lucid.quantization.get_default_qconfig—Uses this as the weight observer.
- lucid.nn.quantized.Linear—Consumes per-channel weight qparams at convert time.
- lucid.quantization.calculate_qparams—The per-channel range → qparams reduction.