ema-pytorch: Weight Averaging, Post-Hoc Synthesis and Target Routing in One Wrapper
A simple way to keep track of an Exponential Moving Average (EMA) version of your Pytorch model
At a glance
- What is it?
- ema-pytorch wraps a torch.nn.Module and keeps a decayed copy of its weights, with an optional post-hoc synthesis path from Karras et al. and a submodule routing mode for self-supervised setups. The package is small and the API is small; the decisions worth making are about warmup, update frequency and checkpoint storage.
- Who is it for?
- Adopt ema-pytorch if you already train PyTorch models and want a decayed weight copy without writing the buffer logic yourself, or if you need the post-hoc synthesis from Karras et al. and would rather not reimplement the checkpoint grid. Do not adopt it as a substitute for a checkpointing strategy: PostHocEMA writes files to checkpoint_folder on a schedule you choose, and that storage is your responsibility.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 46 days ago.
- What is it written in?
- Mainly Python, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 15, 2026, and from our analysis. They are not legal advice.
DEEP OPEN-SOURCE ANALYSIS
The gap ema-pytorch fills between a model and its decayed copy
Training a network and then evaluating a decayed average of its weights is a standard trick, and the code to do it is short enough that most people write it themselves. The version people write is usually wrong in one of three ways: the decay is applied from step zero so early noisy weights pollute the average, the update runs on every step even when the optimizer only steps occasionally, or the step counter is not saved alongside the weights so a resumed run restarts the warmup. ema-pytorch packages the corrected version of that logic. The README describes it as a simple way to keep track of an Exponential Moving Average version of a PyTorch model, and the API surface matches that claim: one wrapper class, a beta, and an update call. The intended user is someone training a model in PyTorch who wants the averaged weights available as a callable module with the same signature as the original. It is not a training framework, it does not touch your optimizer, and it does not decide when to evaluate.
What EMA.update() actually does to the shadow weights
The wrapper holds a copy of the network and moves that copy toward the live network after each update call. Three constructor arguments govern the schedule. beta is the exponential moving average factor, given as 0.9999 in the README example. update_after_step is the number of update calls to skip before any averaging begins; the README uses 100. update_every controls how often the averaging actually runs, described as a way to save on compute, with 10 meaning every tenth call. The README attributes the warmup logic to a suggestion from @crowsonkb and states it has been validated across a number of projects, which is the only provenance offered for that particular choice. The averaged module is reachable as ema.ema_model, and it is callable with the same inputs as the wrapped network, which the README demonstrates by comparing net(data) against ema(data). A separate flag, update_model_with_ema_every, copies the EMA weights back into the live model on a schedule, and ema.update_model_with_ema() does the same thing manually. The README ties that flag to testing the claims of the Switch EMA paper, so it is presented as an experiment hook rather than a default behaviour.
Installing it and wiring the wrapper into a training step
Installation is a single command: pip install ema-pytorch. The README's usage block constructs a torch.nn.Linear(512, 512), wraps it with EMA(net, beta=0.9999, update_after_step=100, update_every=10), mutates the live weights under torch.no_grad(), then calls ema.update(). The order matters: update() reads the current live weights, so calling it before the optimizer step averages the previous state. On saving, the README is explicit that you should save the entire wrapper rather than the inner module, because the wrapper carries the number of steps taken and therefore the warmup state. Saving only ema.ema_model discards that counter. The README does not show a torch.save or state_dict example, so the exact serialization call is left to the reader. That is a small documentation gap, and it is the kind of gap that shows up later as a resumed run that behaves differently from an uninterrupted one.
PostHocEMA: a checkpoint grid instead of a single decay
The second class, PostHocEMA, implements the post-hoc synthesized EMA from Karras et al. (arXiv 2312.02696). Instead of one decay rate you pass sigma_rels, a tuple of at least two hyperparameters; the README example uses (0.05, 0.28). During training the wrapper writes checkpoints on a schedule set by checkpoint_every_num_steps into the directory named by checkpoint_folder, shown as './post-hoc-ema-checkpoints'. After training you call emas.synthesize_ema_model(sigma_rel=0.15) to produce an averaged model at a decay rate you never trained with, and the returned object is callable like any other module. The trade-off is storage and I/O: the synthesis works because a grid of checkpoints exists, so checkpoint_every_num_steps and checkpoint_folder are not incidental settings, they determine how much disk the run consumes. The README says at least two sigma_rels are required to synthesize a new one, and it does not state how synthesis behaves if checkpoint_every_num_steps is set so coarsely that few checkpoints exist. Treat that combination as untested by the documentation.
EMAModuleWrapper and the routing of teacher outputs into student submodules
The third entry point targets nested module trees, which the README frames around self-supervised learning. EMAModuleWrapper takes a mapping in ema_module_kwargs from an online submodule path to the submodule whose EMA output should be injected into its forward pass. In the example, 'branch_a.block1' is mapped to 'branch_b.block1', and the Block.forward signature accepts an ema_output keyword that defaults to None, returning a zero tensor when nothing is injected. The wrapper's own forward call returns a tuple of output and loss, and the README's example calls loss.backward() followed by ema.update(). The keyword name is configurable: passing a dict with ema_module_path and ema_kwarg, as in the 'deep.nested.branch_a' example, renames the injected argument to teacher_latent. For multi-view setups where student and teacher see different augmentations, ema_args or ema_kwargs are passed at call time, shown as ema(student_input, ema_args=teacher_input). The constraint is that the paths are strings resolved against the module tree, so any renaming or restructuring of the model silently breaks the mapping. The README does not describe what happens when a path fails to resolve.
Where the wrapper stops being the right tool
Three cases stand out. First, if your training loop already has a checkpoint and evaluation harness with its own step accounting, dropping in a second step counter creates two sources of truth, and the README's advice to save the whole wrapper makes that duplication permanent in your checkpoint format. Second, if you need per-parameter decay rates or decay schedules that vary by layer, the API exposes one beta for the wrapper; nothing in the README suggests per-group control. Third, PostHocEMA is the wrong choice if disk is the binding constraint, because its whole mechanism depends on retaining a checkpoint grid, and the README gives no guidance on pruning those files after synthesis. The package also does not ship an evaluation loop, a scheduler, or distributed-training coordination; in a multi-process run, each process calling update() on its own wrapper will maintain its own shadow copy, and the README says nothing about that case. Verify it yourself before assuming the averaged weights are consistent across ranks.
How it differs from torch.optim.swa_utils and timm's ModelEmaV2
PyTorch ships torch.optim.swa_utils, whose AveragedModel and update_parameters path is the closest built-in option. The difference is in the averaging rule and the schedule. SWA-style averaging typically accumulates a running mean over a window of epochs, and the built-in utility expects you to drive the update yourself at chosen boundaries. ema-pytorch applies a fixed exponential decay on a step-counted schedule, with an explicit warmup offset and an explicit update interval, and it exposes the result as a callable module rather than only as stored parameters. timm's ModelEmaV2 is closer in spirit: it also keeps a decayed copy and also has a warmup concept, but it is distributed as part of a larger model library rather than as a standalone package, and it does not offer the post-hoc synthesis or the submodule routing that ema-pytorch adds. If you only need a decayed copy and you already depend on timm, adding ema-pytorch duplicates functionality. If you need the Karras-style synthesis or the target routing, the alternatives do not cover it.
Maintenance, versioning and what the MIT licence lets you do
The package is MIT licensed, which permits commercial use, modification and redistribution provided the copyright notice and permission notice are retained. That is a permissive licence with no copyleft obligation on your own code, but it is not legal advice and your organisation's policy on third-party dependencies is the thing to check. On maintenance: the release history supplied shows 0.7.9 and 0.7.8 both landing on 2025-12-19, with 0.7.7 dating to 2024-12-03, and the repository's last push is 2026-07-31. The pairing of two releases on a single day suggests a fix followed by a follow-up, though the material does not say what changed. The practical cost of upgrading is low: the public surface is three classes and a handful of constructor arguments, and the README's examples are the contract. The larger ongoing cost is not the dependency, it is the artifact it produces. PostHocEMA checkpoints accumulate on disk for the length of a run, and the EMA wrapper changes what a checkpoint file contains, since the step counter now lives inside it. Anyone loading old checkpoints into a pipeline that assumes a bare state_dict will need to handle that shape change.
Editorial conclusion
Adopt ema-pytorch if you already train PyTorch models and want a decayed weight copy without writing the buffer logic yourself, or if you need the post-hoc synthesis from Karras et al. and would rather not reimplement the checkpoint grid. Do not adopt it as a substitute for a checkpointing strategy: PostHocEMA writes files to checkpoint_folder on a schedule you choose, and that storage is your responsibility. Before committing, verify three things in your own training loop: that update_after_step and update_every match your step semantics, that you save the whole EMA wrapper rather than ema.ema_model alone so the step counter survives, and that your ema_module_kwargs paths match the actual module names produced by named_modules().
Community notes