from copy import deepcopy
from datetime import datetime
from functools import partial
from pathlib import Path
import torch
import torch.nn as nn
import yaml
import torch_pruning as tp
from ultralytics import YOLO, __version__
from ultralytics.nn.modules import Detect, C2f, Conv, Bottleneck
from ultralytics.nn.tasks import attempt_load_one_weight
from ultralytics.engine.model import Model
from ultralytics.engine.trainer import BaseTrainer
from ultralytics.utils import LOGGER, RANK, DEFAULT_CFG_DICT, DEFAULT_CFG_KEYS
from ultralytics.utils.checks import check_yaml
from ultralytics.utils.torch_utils import initialize_weights, de_parallel
from fastai.vision.all import default_device
from fasterai.prune.all import Pruner
from fasterai.core.criteria import large_final
from fasterai.core.schedule import Schedule, sched_onecycleYOLOv8
Pruner works on any nn.Module, including a detector coming from another library. This page prunes an ultralytics YOLOv8s on coco128, in ten steps, with a fine-tune after each step.
1. Prepare the model
C2f concatenates the two halves of one convolution’s output, which ties channels that the pruner would have to split. replace_c2f_with_c2f_v2 rewrites those blocks into two separate convolutions carrying the same weights. A first fine-tune follows, so that the rest of the run starts from a model already fitted to coco128.
print('ultralytics', __version__, '| torch', torch.__version__)
model = YOLO('yolov8s.pt')
model.__setattr__("train_v2", train_v2.__get__(model)) # pruning-aware trainer, from the hidden cell
cfg = yaml_load(check_yaml('default.yaml'))
cfg.update(data='coco128.yaml', epochs=10, batch=8, verbose=False, amp=False, exist_ok=True)
batch_size = cfg['batch']
model.model.train()
replace_c2f_with_c2f_v2(model.model)
initialize_weights(model.model)
model.model = model.model.to(model.device).float()
validation_model = deepcopy(model) # spare YOLO wrapper, used below to score pruned models
for p in model.model.parameters(): p.requires_grad = True
model.train_v2(pruning=True, **cfg)
print('fine-tuned, weights at', model.trainer.best)ultralytics 8.3.162 | torch 2.9.1+cu128
fine-tuned, weights at runs/detect/train/weights/best.pt
The trainer hands the model back through attempt_load_one_weight, which returns the checkpoint with its gradients turned off. torch-pruning builds its dependency graph by tracing through autograd, so on a frozen model it finds nothing to prune and prune_model() returns without touching anything.
example_inputs = torch.randn(1, 3, cfg['imgsz'], cfg['imgsz']).to(model.device)
def size_of(net):
"GMACs and M parameters of `net`"
macs, params = tp.utils.count_ops_and_params(net, example_inputs)
return f'{macs / 1e9:6.2f} GMACs {params / 1e6:6.2f} M params'
frozen = deepcopy(model.model) # exactly as the trainer returned it
heads = [m for m in frozen.modules() if isinstance(m, Detect)]
print('requires_grad on the reloaded model:', {p.requires_grad for p in frozen.parameters()})
print('before ', size_of(frozen))
Pruner(frozen, 0.15, 'local', large_final, ignored_layers=heads).prune_model()
print('pruned, frozen ', size_of(frozen))
frozen.requires_grad_(True)
Pruner(frozen, 0.15, 'local', large_final, ignored_layers=heads).prune_model()
print('pruned, grads on ', size_of(frozen))requires_grad on the reloaded model: {False}
before 14.36 GMACs 11.17 M params
pruned, frozen 14.36 GMACs 11.17 M params
pruned, grads on 11.08 GMACs 8.39 M params
The frozen model comes back from prune_model() with exactly the parameters it went in with: nothing is raised, nothing is logged. The same model with requires_grad_(True) goes from 11.17 M to 8.39 M parameters and from 14.36 to 11.08 GMACs on one call. The loop below therefore re-enables the gradients before it builds its pruner.
2. Ten pruning steps
ignored_layers keeps the detection head out of the pruning, so the output shapes stay valid. iterative_steps and schedule split the 0.15 channel target over the ten calls to prune_model(): the schedule returns a progress in [0, 1] and the pruner multiplies the target by it.
Each step prints the MACs and parameters of the whole detector and the mAP50-95 before and after the fine-tune of that step.
ignored_layers = [m for m in model.model.modules() if isinstance(m, Detect)]
for p in model.model.parameters(): p.requires_grad = True # see the cell above
pruner = Pruner(model.model, 0.15, 'local', large_final,
ignored_layers=ignored_layers,
iterative_steps=10,
schedule=Schedule(partial(sched_onecycle, α=10, β=4)))
def val_map(net, name):
"mAP50-95 of a DetectionModel, always through the same wrapper and the same validator config"
cfg['name'], cfg['batch'] = name, 1
validation_model.model.model = deepcopy(net.model)
return validation_model.val(**cfg).box.map
# parity: the fine-tuned model scored through the shared wrapper and through a freshly
# loaded YOLO(best), to check the two give the same number before the table uses one of them
init_map = val_map(model.model, 'parity_shared')
probe = YOLO(model.trainer.best)
probe.model = deepcopy(model.model)
cfg['name'], cfg['batch'] = 'parity_reloaded', 1
print(f'parity: same model, two wrappers -> {init_map:.4f} and {probe.val(**cfg).box.map:.4f}')
base_macs, base_params = tp.utils.count_ops_and_params(model.model, example_inputs)
header = f"{'step':>4} {'GMACs':>8} {'M params':>9} {'% of base':>10} {'mAP pruned':>11} {'mAP tuned':>10}"
print()
print(header)
print('-' * len(header))
print(f"{0:>4} {base_macs / 1e9:>8.2f} {base_params / 1e6:>9.2f} {100:>9.1f}% "
f"{'':>11} {init_map:>10.4f}")
for step in range(10):
pruner.prune_model()
macs, params = tp.utils.count_ops_and_params(pruner.model.to(default_device()),
example_inputs.to(default_device()))
map_pruned = val_map(pruner.model, f'step_{step}_pre_val')
cfg['name'], cfg['batch'] = f'step_{step}_finetune', batch_size
model.model = pruner.model
for p in model.model.parameters(): p.requires_grad = True
model.train_v2(pruning=True, **cfg)
best = model.trainer.best
map_tuned = val_map(model.model, f'step_{step}_post_val')
print(f"{step + 1:>4} {macs / 1e9:>8.2f} {params / 1e6:>9.2f} "
f"{100 * params / base_params:>9.1f}% {map_pruned:>11.4f} {map_tuned:>10.4f}")
torch.cuda.empty_cache() # the deepcopies above keep the GPU busy between steps
if init_map - map_tuned > 0.2:
print('mAP dropped by more than 0.2 - stopping early')
breakparity: same model, two wrappers -> 0.6484 and 0.6484
step GMACs M params % of base mAP pruned mAP tuned
---------------------------------------------------------
0 14.36 11.17 100.0% 0.6484
1 14.10 11.01 98.6% 0.6149 0.6728
2 13.81 10.76 96.4% 0.6214 0.6708
3 13.32 10.33 92.5% 0.6190 0.6805
4 12.62 9.70 86.9% 0.5092 0.6694
5 11.82 9.05 81.1% 0.4635 0.6413
6 11.45 8.72 78.1% 0.4069 0.6396
7 11.19 8.51 76.2% 0.5652 0.6471
8 11.16 8.46 75.7% 0.6423 0.6670
9 11.08 8.39 75.1% 0.6490 0.6718
10 11.08 8.39 75.1% 0.6664 0.6741
Ten steps took the detector from 11.17 M to 8.39 M parameters and from 14.36 to 11.08 GMACs, 75.1% of what it started with. mAP pruned is the model right after a pruning step, mAP tuned after that step’s ten epochs; both columns go through the same wrapper and the same validator configuration, and the parity line above shows that wrapper agreeing with a freshly loaded YOLO(best) to the fourth decimal.
coco128 validates on its own training images, so both columns are a fit on the 128 images the step has just trained on. The pruned column dips to 0.4069 at step 6 and comes back; the tuned column stays between 0.6396 and 0.6805, and the last two steps move almost nothing — the one-cycle schedule has reached its target by then.
3. Check the model the loop left behind
best is the checkpoint the last fine-tune wrote. The validation below reuses cfg, so the validator runs with the same configuration as the table.
final = YOLO(best)
final_macs, final_params = tp.utils.count_ops_and_params(final.model, example_inputs.to(final.device))
print(f'{best}')
print(f'MACs {base_macs / 1e9:.2f} G -> {final_macs / 1e9:.2f} G')
print(f'params {base_params / 1e6:.2f} M -> {final_params / 1e6:.2f} M')runs/detect/step_9_finetune/weights/best.pt
MACs 14.36 G -> 11.08 G
params 11.17 M -> 8.39 M
cfg['name'], cfg['batch'] = 'final_val', 1
metrics = final.val(**cfg) # the same validator configuration as the table above
print(f'mAP50-95 {metrics.box.map:.4f} mAP50 {metrics.box.map50:.4f}')mAP50-95 0.6741 mAP50 0.8714
4. Export
Under torch 2.9, ultralytics asks the ONNX exporter for an opset it can no longer produce, and the down-conversion fails; passing opset=18 avoids it.
onnx_path = final.export(format='onnx', opset=18)
print(onnx_path)Applied 1 of general pattern rewrite rules.
runs/detect/step_9_finetune/weights/best.onnx
Measured on: ultralytics 8.3.162 and torch 2.9.1+cu128 (both printed above), YOLOv8s, coco128, batch 8 for the fine-tunes and 1 for validation, AMP off, a shared GPU, single run, one seed.
coco128.yaml uses images/train2017 as its validation split, so every mAP on this page is measured on the 128 images the model is fine-tuned on: a fit, not a generalisation number, and each row also carries the ten extra epochs its step added. Reloading the final checkpoint and validating it with the same cfg gives 0.6741, the last row of the table. MACs are a compute count at the model’s default image size; no inference time was measured.
Summary
| Call | What it gives you |
|---|---|
Pruner(model.model, 0.15, 'local', large_final, ...) |
a pruner over the ultralytics DetectionModel; the ratio is a fraction of the channels |
ignored_layers=[m for m in ... if isinstance(m, Detect)] |
the detection head left untouched, so the output shapes stay valid |
iterative_steps=10, schedule=Schedule(...) |
the target spread over ten calls to prune_model(), following the schedule’s progress |
pruner.prune_model() |
one pruning step, applied in place |
tp.utils.count_ops_and_params(model, x) |
MACs and parameters of the current graph (torch-pruning) |
model.export(format='onnx', opset=18) |
ONNX export of the pruned detector |
Two things this page needs that a torchvision model does not: C2f has to be rewritten into two separate convolutions before its channels can be pruned, and the parameters have to be set back to requires_grad=True after the trainer reloads the model from its checkpoint — the cell in section 1 shows what happens otherwise.
See Also
- Pruner - the same API on a torchvision ResNet-18
- PruneCallback - prune inside a fastai training loop
- Criteria - how filter importance is scored
- Schedules - the progress functions passed as
schedule=