Timm to lux
Workflow guide for porting a PyTorch `timm` model to a numerically-equivalent Lux.jl implementation. Load this skill whenever the task involves porting, converting, or translating a PyTorch model (especially anything from `timm.create_model`, `forward_features`, ResNet/ViT/EfficientNet/ConvNeXt/etc. backbones) to Lux; writing or editing Lux `@compact` blocks that must match a PyTorch reference; producing or consuming HDF5 parity fixtures, `read_parity`, `apply_state_dict`, or `@test isapprox` parity tests; loading `.safetensors` weights from HuggingFace Hub in a Julia context; or reasoning about PyTorch-vs-Lux numeric differences (cross-correlation vs convolution, padding semantics, GroupNorm/BatchNorm defaults, weight standardization, NCHW vs WHCN).From its SKILL.md
npx -y skills add csvance/Luximm.jl --skill timm-to-luxAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 10 stars10 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
22.8 KB, as published. Nobody here has run it
Porting timm models to Lux.jl
A reference for converting a PyTorch timm backbone, layer, or building block into a numerically-equivalent Lux.jl implementation that loads pretrained weights from HuggingFace.
The repository at campfire/lib/MMILux.jl contains a worked, parity-verified port (BiTResNet.jl) plus the supporting utilities (Parity.jl, the test/parity/ Python sidecars). Use it as the canonical pattern. Do not re-implement what is already there.
1. Workflow at a glance
Seven phases, run in order. Each phase has a cheap correctness gate before you move on.
- Capture parity fixtures from
timmin Python: end-to-end input/output, per-stage intermediates, one block per stage. - Stub the host Julia package layout (
src/<Model>.jl,test/test_<model>.jl,test/parity/dump_<model>.py,data/parity/<model>.h5) following the MMILux conventions. The host may be MMILux.jl (for internal layers) or a fresh standalone package intended for public release; see section 3 for which utilities to vendor in the latter case. - Implement layers with
@compact, matching PyTorch's numeric conventions explicitly. - Keep the forward pass autodiff- and GPU-safe by default.
- Wire pretrained
.safetensorsloading from HuggingFace as the production weight path. - Verify parity end-to-end, then narrow on any divergence using the per-stage and per-block fixtures.
- Iterate via Kaimon + Revise, restarting the REPL only when struct or include topology changes.
Pick one variant and finish it before starting the next. When a model family has many variants (ResNet-50/101/152, ViT-B/L/H, EfficientNet-B0..B7), porting the smallest variant first is almost always the right call: it surfaces every shared numeric trap (cross-correlation, padding, norm defaults, axis order) with the fastest test loop, and the weight-mapping function written for one variant typically generalizes by changing only depths/widths. Resist the temptation to architect for all variants up front. Land parity on one. Then add the second by parameterizing what differs, not by writing a second implementation.
2. Phase 1: capture parity fixtures from timm
The fixture is an HDF5 file built by a small Python sidecar in test/parity/. Reuse _dump_common.py from campfire/lib/MMILux.jl/test/parity/_dump_common.py verbatim (copy it into the target project, or symlink it if the project shares a workspace with campfire). It writes /input, /state_dict/<key> for every PyTorch parameter, and either /output as a single dataset or /output/<name> as a group of named outputs. The Julia side reads it via read_parity(path) (imported from MMILux or vendored into the host package, per section 3).
Capture more than one fixture per port. The end-to-end one alone gives a pass/fail signal with no localization power. Add:
- Multiple inputs. Three to five different random seeds and at least one off-default shape, dumped as
/output/seed0,/output/seed1, etc. Catches seed-tuned bugs and shape-dependent errors. - Per-stage intermediates. Register a
forward_hookon each top-level stage module (model.stages[0],model.stages[1], ...) and store its output under/output/stage0,/output/stage1, etc. Required for bisecting divergence. - Per-block I/O. For one representative block in each stage, dump its input and output as a separate fixture file (
<model>_block_stage1.h5). Lets you test the block in isolation without standing up the whole forward.
The Python loop looks like this:
model = timm.create_model("resnetv2_50x1_bit.goog_in21k",
pretrained=True, in_chans=1, num_classes=0)
model.eval()
stage_outs = {}
hooks = [s.register_forward_hook(
lambda m, _, o, k=f"stage{i}": stage_outs.__setitem__(k, o.detach()))
for i, s in enumerate(model.stages)]
inp = torch.randn(1, 1, 224, 224)
with torch.no_grad():
final = model.forward_features(inp)
for h in hooks: h.remove()
out = {"final": final, **stage_outs}
dump(OUT_PATH, inp=inp, state_dict=model.state_dict(), out=out)
in_chans=1 triggers timm.layers.adapt_input_conv so the stem weight in state_dict is already collapsed to one input channel. Do not re-collapse on the Julia side. Use forward_features when porting the backbone, forward when porting the full classifier; the fixture and the Julia forward must agree on which one.
3. Phase 2: reuse the existing utilities, do not re-implement
First, pick the host package. Two cases:
- The port lives in MMILux.jl (the existing campfire-internal package). Import the utilities below directly:
using MMILux: read_parity, apply_state_dict, axis_reverse, pyperm. This is the right home for internal layers and one-off ports, not for code intended for public release. - The port lives in a new standalone Julia package, intended for eventual publication of the backbone library separate from MMILux. In this case, vendor the same four utilities into the new package's
src/Parity.jlverbatim (it is ~100 lines of permissively-structured code), preserving the function names and signatures so the rest of this skill's guidance applies unchanged. Do not take a hard dependency on MMILux from a package that is meant to be public.
Either way, the four functions below are load-bearing and must be reachable by these exact names:
read_parity(path)returns(input, state_dict, output)asFloat32arrays. Tensors come back in their HDF5-natural Julia layout, which is the reverse of the PyTorch logical axis order. PyTorch(N, C, H, W)becomes Julia(W, H, C, N), which is exactly Lux's WHCN. Conv weight(out, in, kH, kW)becomes(kW, kH, in, out), which is exactly Lux's Conv layout. Bias(C,)stays(C,). For most parameters and activations, the HDF5-natural layout is what you want and the transform isidentity.apply_state_dict(ps, state_dict, mapping)rebuilds the parameterNamedTupleby setting leaves from the dict. Mapping entries are(pytorch_key, lux_path_tuple, transform). Non-mutating; bind the result.axis_reverse(a)andpyperm(perm)are the two ready-made transforms for the cases where the HDF5-natural layout is not what you want (a custom tensor whose Julia axes were designed in PyTorch order, or a layer like DSNT2D whose output axes are hand-arranged).
Keep the weight mapping function in the model file, named <model>_mapping(state_dict; prefix::Tuple{Vararg{Symbol}} = ()), returning a Vector{Tuple{String, Tuple{Vararg{Symbol}}, Function}}. The prefix arg lets a backbone be nested under a wrapper model (e.g. prefix = (:backbone,)). BiTResNet.jl:162-204 is the reference.
4. Phase 3: implement layers with @compact
Lux's @compact is the right primitive for composing layers (https://lux.csail.mit.edu/stable/api/Lux/utilities#Lux.@compact). The pattern is fixed:
@compact(
conv1 = Conv((3, 3), in_ch => out_ch; pad = 1, cross_correlation = true),
norm1 = GroupNorm(out_ch, 32; affine = true, epsilon = 1f-5),
) do x
@return NNlib.relu.(norm1(conv1(x)))
end
Numeric conventions that bite if you forget them:
- Cross-correlation, always. PyTorch's
Conv2dis cross-correlation; Lux'sConvdefaults to true convolution (kernel-flipped). Passcross_correlation = trueto everyConv. Without it, weights load with the right shape but produce mirrored outputs. When you must drop intoNNlib.convdirectly (weight standardization is the canonical case), passflipkernel = truetoNNlib.DenseConvDims. They are the same semantic. - Explicit padding when zero-padding matters.
Conv((k, k), ...; pad = p)works for symmetric same-value padding. For asymmetric padding, or for pooling that must pad with zeros instead of-Inf, callNNlib.pad_zeros(x, (l, r, t, b, 0, 0, 0, 0))before the op and usepad = 0on the op itself. timm's BiT stem is the canonical case:ConstantPad2d(value=0)thenMaxPoolwith no padding. The Lux equivalent is inBiTResNet.jl:38-39. - Norm defaults are not portable. Always pass
epsilonandaffineexplicitly onGroupNorm/LayerNorm/BatchNorm. PyTorch'snn.GroupNormuseseps=1e-5; Lux's default differs. Mismatched epsilons silently shift activations and look like a flaky parity failure. - Variance corrections. Sample variance (
Bessel-corrected, the Julia default) and population variance (corrected = false, what PyTorch uses for BN-style stats and for weight standardization) differ by a factor ofN/(N-1). Passcorrected = falsetovarwhenever you're matching a BN/WS-style operation. Seestd_convatBiTResNet.jl:73-75. Lux.testmode(st)for parity tests. Otherwise BN running stats update and dropout activates, neither of which is whatmodel.eval()does on the PyTorch side.
Multi-variant architectures. Even though you port one variant first, design the constructor so the next variant is a parameter change. Lift widths, depths, strides into a tuple inside one shared constructor. Expose family variants through a top-level dispatcher:
const _RESNET_SPECS = Dict(
:resnet50 => (widths = (256, 512, 1024, 2048), depths = (3, 4, 6, 3)),
:resnet101 => (widths = (256, 512, 1024, 2048), depths = (3, 4, 23, 3)),
:resnet152 => (widths = (256, 512, 1024, 2048), depths = (3, 8, 36, 3)),
)
function resnet(family::Symbol; in_chans::Int = 3, num_classes::Int = 0,
pretrained::Bool = false)
spec = _RESNET_SPECS[family]
return _resnet_impl(; spec..., in_chans, num_classes, pretrained, family)
end
But do not add :resnet101 and :resnet152 to the dict until :resnet50 passes parity. Adding them earlier creates code paths nothing has exercised and that the first failed parity test will not localize. Each new variant is its own small port: dump a fresh fixture, run the parity test, add the entry.
User-facing knobs every constructor exposes:
in_chans::Int(default matchestimm's default for the family, typically 3).num_classes::Int = 0. Zero means the classifier head is omitted and the forward returns features.- Anything else the architecture genuinely exposes:
drop_rate,stem_type, etc. Do not invent knobs for parity completeness; only mirror whattimm.create_modelaccepts. - The family constructor itself never takes
pretrained. Weight loading is a separateload_<family>_pretrained(ps, st, variant; ...) -> (ps, st)function, reachable through the family-agnosticload_pretrained(ps, st, variant; ...)dispatcher insrc/Models/Models.jl. Keep the constructor pure so tests can build random-init models without touching the network and so the model composes cleanly inside a larger@compactblock.
5. Phase 4: autodiff- and GPU-safe code by default
Every forward must work under at least Zygote and Reactant, and must not assume CPU arrays. The rules:
- No in-place writes to params or activations. No
x[i] = ..., nosetindex!, no.+=. Build new arrays withcat,reshape,permutedims, broadcasting, andvcat/hcat.DSNT.jl:60-66shows the cat-based stacking pattern instead of an indexed assignment. - No
Array(x),collect(x), orcpu(x)inside the forward. They move GPU arrays back to host and break the gradient graph. Constants the forward needs (coordinate grids, normalized linspaces, fixed buffers) belong in the@compactcapture list. Lux'ssetupwill move them to the active device automatically. - No scalar indexing. Calls like
x[1, 2, 3]in a kernel context will throw or warn onCuArrayand on Reactant tracers. Express the same access through broadcasting,getindexwith ranges, or a reshape. - No
if/elseon tensor values in the forward. Branching onif x > 0against anAbstractArrayis a non-starter under autodiff. Use masks,clamp,NNlib.relu,ifelse.(...), or the appropriate activation primitive. - Use
Lux.testmode(st)for parity tests. Otherwise BN running stats update and any dropout activates.
The forward should look like math: a sequence of broadcasts and tensor ops with no scalar control flow.
6. Phase 5: HuggingFace .safetensors for the production weight path
Parity fixtures bake the state_dict into the HDF5 file so the test can run offline. Production users should not pay that round trip; they build the model with create_model(:resnet50), run Lux.setup, then stream the weights in from HuggingFace via load_pretrained(ps, st, :resnet50; ...).
Steps:
-
Add SafeTensors.jl to the host package. From inside Kaimon:
pkg_add(packages=["SafeTensors", "Downloads"], ses=<key>). Downloads is stdlib but must be inProject.tomlif a package module uses it. -
Pin the URL per variant in source. Use the
timmHugging Face repos underhttps://huggingface.co/timm/. Resolve the actual.safetensorsURL with/resolve/main/model.safetensors. Comment the timm canonical name next to the constant so the URL can be re-derived if it ever drifts:# timm: resnet50.a1_in1k const RESNET50_URL = "https://huggingface.co/timm/resnet50.a1_in1k/resolve/main/model.safetensors" -
Define one download helper per package, not per model:
function _hf_download(url::AbstractString, dest::AbstractString) isfile(dest) && return dest mkpath(dirname(dest)) headers = Pair{String,String}[] token = get(ENV, "HUGGING_FACE_HUB_TOKEN", "") isempty(token) || push!(headers, "Authorization" => "Bearer $token") Downloads.download(url, dest; headers = headers) return dest endThe token is optional. Public
timmweights download anonymously; the header only matters for gated or private repos. Cache underjoinpath(@__DIR__, "..", "weights", "<file>")to match the BiT convention, or under~/.cache/<pkg>/if the host package prefers a user-scoped cache. -
Define one loader per family with the shared signature
(ps, st, variant; kwargs...) -> (ps, st):function load_resnet_pretrained(ps, st, variant::Symbol; num_classes::Int = 0, in_chans::Int = 3, revision::AbstractString = "main", cache_dir::AbstractString = hf_hub_cache_dir(), prefix::Tuple{Vararg{Symbol}} = ()) cfg = RESNET_VARIANTS[variant] path = hf_hub_download(cfg.hf_repo, "model.safetensors"; revision = revision, cache_dir = cache_dir) sd = load_safetensors_state_dict(path) ps = apply_state_dict(ps, sd, resnet_mapping(sd, variant; prefix = prefix)) # If the family has BatchNorm running stats, also apply them into `st`. return ps, st endStateless families (GroupNorm / LayerNorm) just thread
stthrough unchanged. ResNet-style families mutatestvia a parallel<family>_state_mappingandapply_state_dict. The uniform return shape is what letsload_pretrained(ps, st, variant)dispatch on the symbol.Axis-order gotcha.
SafeTensors.loadreturns arrays in PyTorch logical axis order (NCHW for activations,(out, in, kH, kW)for conv weights), not the reversed layout thatread_parityproduces from HDF5. Two acceptable resolutions:- Recommended: normalize the SafeTensors dict to the HDF5-natural reversed layout once at load time by applying
axis_reverseto every tensor whose Julia layout inapply_state_dictis reversed. Then the same<model>_mappingworks for both fixture-driven tests and production loading. - Alternative: keep two mapping functions, one for fixtures and one for safetensors. More code; easier to silently drift.
- Recommended: normalize the SafeTensors dict to the HDF5-natural reversed layout once at load time by applying
-
Family-agnostic dispatch is already wired. Once the family's
<FAMILY>_VARIANTSdict andload_<family>_pretrainedexist, the variant becomes reachable throughcreate_model(variant; ...)andload_pretrained(ps, st, variant; ...)insrc/Models/Models.jlautomatically — no extra plumbing per port. Both entry points are type-stable on the Union axis (create_modelalways returns just the model,load_pretrainedalways returns(ps, st)). Keep the constructor and the weight loading separate so test code can build random-init models without network access and so the model composes cleanly inside a larger@compactblock.
7. Phase 6: verify parity, narrow on divergence
The verification loop is layered. Run the cheap gates between every meaningful edit.
End-to-end first, against the fixture's state_dict (not the HF download) so the test isolates the forward pass:
Logits (num_classes > 0): absolute max-abs-diff under LOGITS_ATOL.
data = read_parity(_FIXTURE_PATH)
model = resnet(:resnet50; in_chans = 3)
rng = Xoshiro(0)
ps, st = Lux.setup(rng, model)
st = Lux.testmode(st)
ps = apply_state_dict(ps, data.state_dict, resnet50_mapping(data.state_dict))
y, _ = model(data.input, ps, st)
diff = maximum(abs.(y .- data.output["logits"]))
@test diff < 1f-3 # LOGITS_ATOL
Features (num_classes = 0): relative bar — absolute diff divided by the max absolute value of the timm reference. This keeps the check scale-free across tiny-through-huge variants whose raw pre-norm activations span very different magnitudes.
model_f = resnet(:resnet50; in_chans = 3, num_classes = 0)
ps_f, st_f = Lux.setup(rng, model_f)
st_f = Lux.testmode(st_f)
ps_f = apply_state_dict(ps_f, data.state_dict, resnet50_mapping(data.state_dict))
y_f, _ = model_f(data.input, ps_f, st_f)
diff = maximum(abs.(y_f .- data.output["features"]))
rel = diff / max(maximum(abs.(data.output["features"])), eps(Float32))
@test rel < 1f-4 # FEATURES_RTOL
When it fails, the per-stage and per-block fixtures earn their keep. Walk the forward by hand:
- For each stage, run the partial forward up to that stage (the simplest way is a transient
@compactthat drops in the relevant sub-models, or peeling layers off the fullChain). Compare againstdata.output["stage_i"]. The first stage where parity breaks localizes the bug to that stage's blocks. - Inside the failing stage, instantiate a single block (
preact_bottleneck(in, out, stride; ...)), callLux.setup, splice in the matchingstate_dictentries via a small mapping, and check against the per-block fixture. This is faster than printing intermediate tensors and survives random init.
Divergence-pattern playbook:
- Off by a few ULP, structured. Float32 cast on the Python side is already in
_dump_common.to_numpyso that's not it. Suspectcross_correlation,corrected = false, or anepsilonmismatch on a norm. - Off by a lot, structured. Suspect (a)
cross_correlation = truemissing on aConv, (b)-Infvs zero padding on amaxpool, (c) flipped axes from a mapping transform mismatch. - Off by a lot, random-looking. Suspect that you applied
apply_state_dictto the wrong sub-tree (prefixwrong) or that astate_dictkey spelled differently in your mapping silently fell back to random init. Add anerrorin your mapping function that fails on any key absent fromstate_dict.keys();BiTResNet.jl:199-202does this at the bottom of the mapping builder. - Stages 0..k pass, stage k+1 fails. Confirms the bug is local to stage k+1's first block; bisect within that block (norm before conv? conv first vs norm first?). The order of operations in pre-activation ResNet blocks differs from post-activation; both compile, only one matches.
8. Phase 7: iterate via Kaimon + Revise
Stand up one start_session()-attached REPL and keep it alive across the whole port. Edit .jl files; Revise picks up function-body changes between ex calls. Re-run the parity test via ex(e="include(\"test/test_<model>.jl\")", ses=<key>). Restart only when:
- You added or removed a struct field, including
@compactlayer fields. - You added or removed an
include. - A long-lived REPL state (a cached
Lux.setupresult, a fixture loaded into a global) is stale.
The per-block parity tests are well-suited to be a smoke_test()-style gate: a few microseconds per call, structured small output, runs after every edit. The full end-to-end parity is more expensive and goes at the end of each iteration.
9. Pitfalls reference
A running checklist; every item below has cost real time on a previous port.
- Cross-correlation. Every
Convneedscross_correlation = true. EveryNNlib.convneedsflipkernel = trueon itsDenseConvDims. Same semantic, two flags. - Zero-padded pooling.
NNlib.maxpool(pad=1)pads with-Inf.nn.MaxPool2dwithpadding=1pads with zero. When the input has negative values (post-norm activations, leaky-relu outputs), this diverges.pad_zerosfirst, then pool withpad = 0. - GN/BN/LN defaults. Always pass
epsilonandaffineexplicitly. Never trust the Lux default to match the PyTorch default. - Variance correction. Pass
corrected = falsewhen matching anything BN-style or WS-style. - Weight axis order on
apply_state_dict. HDF5 fixtures arrive reversed (Lux-natural). SafeTensors arrive PyTorch-natural and must be reversed in the transform (or normalized at load time). forward_featuresvsforward. The fixture and the Julia forward must agree on which one was dumped. Mismatch shows up as a(N, num_classes)vs(N, C, H, W)shape error if you're lucky, a silent wrong number if you're not.- WHCN vs NCHW.
read_parityreturns WHCN-natural already. If you bypass it and callHDF5.readdirectly, you get reversed-PyTorch (which is WHCN), which is what you want, but it's easy to convince yourself you need to permute "back" and break it. - Pre-activation block order. norm -> activation -> conv vs conv -> norm -> activation. Both compile. Only one matches the upstream architecture.
- timm stem adaptation for
in_chans != 3.adapt_input_convcollapses the stem atstate_dicttime. Do not re-collapse on the Julia side. Lux.testmode(st). Required for parity. Forgetting it makes the test occasionally pass and occasionally fail depending on RNG state inside dropout.pretrainedURLs go stale. Pin them next to a comment giving thetimmcanonical name. Public timm weights live athttps://huggingface.co/timm/<name>/resolve/main/model.safetensors.- Don't generalize until one variant works. Adding
:resnet101to the dispatcher before:resnet50passes parity creates code paths that the first failed test cannot localize.