spikingjelly.activation_based.ann2snn.recipes.local_threshold_balancing 源代码

from __future__ import annotations

import math
from typing import Dict, Iterable, Iterator, TYPE_CHECKING, Tuple, Union

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch import fx
from tqdm import tqdm

from spikingjelly.activation_based import neuron
from spikingjelly.activation_based.ann2snn.modules import ChannelVoltageScaler
from spikingjelly.activation_based.ann2snn.rules import ReLURule
from spikingjelly.activation_based.ann2snn.recipes.base import ConversionRecipe
from spikingjelly.activation_based.ann2snn.recipes.rate_coding import (
    RateCodingRecipe,
    validate_rate_coding_mode,
)
from spikingjelly.activation_based.ann2snn.recipes.step_mode_adapters import (
    _RATE_CODING_SAFE_MODULE_TYPES,
    _RATE_CODING_STATELESS_MODULE_TYPES,
    adapt_step_mode_graph,
)

if TYPE_CHECKING:
    from spikingjelly.activation_based.ann2snn.converter import Converter


__all__ = ["LocalThresholdBalancingRecipe"]


class LocalThresholdBalancingHook(nn.Module):
    def __init__(
        self,
        mode: Union[str, float] = "99.9%",
        channel_dim: int = 1,
        threshold_candidates: Tuple[float, ...] = (0.5, 0.75, 1.0, 1.25, 1.5),
        time_steps: int = 64,
        eps: float = 1e-6,
    ) -> None:
        super().__init__()
        self.mode = mode
        self.channel_dim = channel_dim
        self.threshold_candidates = tuple(float(v) for v in threshold_candidates)
        self.time_steps = int(time_steps)
        self.eps = float(eps)
        self.register_buffer("scale", torch.empty(0))
        self.register_buffer("threshold", torch.empty(0))

    @staticmethod
    def _normalize_channel_dim(x: torch.Tensor, channel_dim: int) -> int:
        if x.dim() < 2:
            raise ValueError(
                "LocalThresholdBalancingRecipe requires activation tensors with "
                "at least 2 dimensions."
            )
        if channel_dim < 0:
            channel_dim += x.dim()
        if channel_dim < 0 or channel_dim >= x.dim():
            raise ValueError("channel_dim is out of range.")
        return channel_dim

    @staticmethod
    def _channel_view(values: torch.Tensor, x: torch.Tensor, channel_dim: int):
        shape = [1] * x.dim()
        shape[channel_dim] = values.numel()
        return values.reshape(shape)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        channel_dim = self._normalize_channel_dim(x, self.channel_dim)
        x_nonnegative = torch.clamp(x, min=0)
        x_detached = x_nonnegative.detach()
        if x_detached.dtype in [torch.float16, torch.bfloat16]:
            x_stat = x_detached.to(torch.float32)
        else:
            x_stat = x_detached

        if self.threshold.numel() == 0:
            self.threshold = torch.zeros(
                x.shape[channel_dim], device=x_stat.device, dtype=x_stat.dtype
            )

        threshold = self.threshold.to(device=x_stat.device, dtype=x_stat.dtype)
        threshold_view = self._channel_view(threshold, x_stat, channel_dim)
        reduce_dims = tuple(dim for dim in range(x_stat.dim()) if dim != channel_dim)
        overflow = torch.clamp(x_stat - threshold_view, min=0)
        threshold = threshold + 2.0 * overflow.mean(dim=reduce_dims)
        threshold = torch.clamp(threshold, min=self.eps)
        self.threshold = threshold.detach()
        self.scale = self.threshold

        clipped_threshold = self._channel_view(
            threshold.to(device=x.device, dtype=x.dtype), x, channel_dim
        )
        return torch.minimum(x_nonnegative, clipped_threshold)

    def compute_threshold(self) -> torch.Tensor:
        if self.threshold.numel() > 0:
            threshold = self.threshold
        elif self.scale.numel() > 0:
            threshold = self.scale
        else:
            raise ValueError("No calibration activations have been recorded.")
        if not torch.isfinite(threshold).all() or (threshold <= 0).any():
            raise ValueError("Balanced thresholds must be finite positive values.")
        return threshold.detach()


class LocalThresholdBalancingReLURule:
    def __init__(self, channel_dim: int = 1) -> None:
        self.channel_dim = channel_dim
        self.relu_rule = ReLURule()

    def match(self, node: fx.Node, modules: Dict[str, nn.Module]) -> bool:
        return self.relu_rule.match(node, modules)

    def insert_hooks(
        self,
        fx_model: fx.GraphModule,
        node: fx.Node,
        hook_factory: "LocalThresholdBalancingHookFactory",
        hook_counts_per_prefix: Dict[str, int],
    ) -> fx.Node:
        if not isinstance(node.target, str):
            raise TypeError("node.target must be a module path string.")
        parent, _, _ = node.target.rpartition(".")
        key = parent or "__FIRST_LEVEL_OF_MODULE__"
        counter = hook_counts_per_prefix.get(key, 0)
        hook_counts_per_prefix[key] = counter + 1
        target = f"{parent}.ltb_hook_{counter}" if parent else f"ltb_hook_{counter}"
        modules = dict(fx_model.named_modules())
        hook_input = node
        users = list(node.users)
        if len(users) == 1:
            user = users[0]
            if (
                user.op == "call_module"
                and isinstance(user.target, str)
                and isinstance(modules.get(user.target), nn.MaxPool2d)
            ):
                hook_input = user
        fx_model.add_submodule(target=target, m=hook_factory.create())
        with fx_model.graph.inserting_after(n=hook_input):
            hook_node = fx_model.graph.call_module(target, args=(hook_input,))
        for user in list(hook_input.users):
            if user is not hook_node:
                user.replace_input_with(hook_input, hook_node)
        return hook_node

    def find_replacements(
        self, fx_model: fx.GraphModule, modules: Dict[str, nn.Module]
    ) -> Iterator[Tuple[fx.Node, fx.Node]]:
        for hook_node in fx_model.graph.nodes:
            if hook_node.op != "call_module":
                continue
            if not isinstance(
                modules.get(hook_node.target), LocalThresholdBalancingHook
            ):
                continue
            if len(hook_node.args) == 0 or not isinstance(hook_node.args[0], fx.Node):
                continue
            hook_input_node = hook_node.args[0]
            if hook_input_node.op != "call_module":
                continue
            if hook_input_node.target not in modules:
                continue
            if self.match(hook_input_node, modules):
                yield hook_input_node, hook_node
                continue
            if not isinstance(modules.get(hook_input_node.target), nn.MaxPool2d):
                continue
            if len(hook_input_node.args) == 0 or not isinstance(
                hook_input_node.args[0], fx.Node
            ):
                continue
            activation_node = hook_input_node.args[0]
            if (
                activation_node.op == "call_module"
                and activation_node.target in modules
                and self.match(activation_node, modules)
            ):
                yield activation_node, hook_node

    def replace_with_neurons(
        self,
        fx_model: fx.GraphModule,
        activation_node: fx.Node,
        hook_node: fx.Node,
    ) -> None:
        if len(activation_node.args) != 1:
            raise ValueError(
                f"The activation node {activation_node.target!r} must have exactly "
                f"1 argument, but got {len(activation_node.args)}."
            )
        if not isinstance(hook_node.target, str):
            raise TypeError("hook_node.target must be a module path string.")
        hook = fx_model.get_submodule(hook_node.target)
        if not isinstance(hook, LocalThresholdBalancingHook):
            raise TypeError("hook_node must target a LocalThresholdBalancingHook.")
        if len(hook_node.args) != 1 or not isinstance(hook_node.args[0], fx.Node):
            raise ValueError("hook_node must have exactly one FX node input.")
        hook_input_node = hook_node.args[0]
        pre_spike_maxpool = False
        if hook_input_node is not activation_node:
            if not (
                hook_input_node.op == "call_module"
                and isinstance(hook_input_node.target, str)
                and isinstance(
                    fx_model.get_submodule(hook_input_node.target), nn.MaxPool2d
                )
            ):
                raise TypeError(
                    "LocalThresholdBalancingRecipe only supports hooks after "
                    "ReLU or after ReLU -> MaxPool2d."
                )
            pre_spike_maxpool = True
            hook_input_node.replace_input_with(activation_node, activation_node.args[0])
        threshold = hook.compute_threshold()
        hook_parent, _, hook_leaf = hook_node.target.rpartition(".")
        spike_leaf = hook_leaf.replace("ltb_hook_", "ltb_spiking_")
        prefix = f"{hook_parent}.{spike_leaf}" if hook_parent else spike_leaf

        target0 = f"{prefix}.scaler0"
        target1 = f"{prefix}.if_node"
        target2 = f"{prefix}.scaler1"
        m0 = ChannelVoltageScaler(1.0 / threshold, channel_dim=self.channel_dim)
        m1 = neuron.HalfThresholdIFNode()
        m2 = ChannelVoltageScaler(threshold, channel_dim=self.channel_dim)

        fx_model.add_submodule(target=target0, m=m0)
        with fx_model.graph.inserting_after(n=hook_node):
            spike_input_args = (
                (hook_input_node,) if pre_spike_maxpool else activation_node.args
            )
            node0 = fx_model.graph.call_module(target0, args=spike_input_args)
        fx_model.add_submodule(target=target1, m=m1)
        with fx_model.graph.inserting_after(n=node0):
            node1 = fx_model.graph.call_module(target1, args=(node0,))
        fx_model.add_submodule(target=target2, m=m2)
        with fx_model.graph.inserting_after(n=node1):
            node2 = fx_model.graph.call_module(target2, args=(node1,))

        hook_node.replace_all_uses_with(node2)
        fx_model.graph.erase_node(hook_node)
        if not pre_spike_maxpool:
            activation_node.replace_all_uses_with(node2)
        fx_model.graph.erase_node(activation_node)


class LocalThresholdBalancingHookFactory:
    def __init__(
        self,
        mode: Union[str, float],
        channel_dim: int,
        threshold_candidates: Tuple[float, ...],
        time_steps: int,
        eps: float,
    ) -> None:
        self.mode = mode
        self.channel_dim = channel_dim
        self.threshold_candidates = threshold_candidates
        self.time_steps = time_steps
        self.eps = eps

    def create(self) -> LocalThresholdBalancingHook:
        return LocalThresholdBalancingHook(
            mode=self.mode,
            channel_dim=self.channel_dim,
            threshold_candidates=self.threshold_candidates,
            time_steps=self.time_steps,
            eps=self.eps,
        )


[文档] class LocalThresholdBalancingRecipe(ConversionRecipe): def __init__( self, dataloader: Iterable, time_steps: int = 64, mode: Union[str, float] = "99.9%", channel_dim: int = 1, threshold_candidates: Tuple[float, ...] = (0.5, 0.75, 1.0, 1.25, 1.5), fuse_flag: bool = True, eps: float = 1e-6, ) -> None: r""" **API Language** - :ref:`中文 <LocalThresholdBalancingRecipe.__init__-cn>` | :ref:`English <LocalThresholdBalancingRecipe.__init__-en>` ---- .. _LocalThresholdBalancingRecipe.__init__-cn: * **中文** 构造 training-free local-threshold-balancing ANN2SNN 转换 recipe。该 recipe 只使用校准数据在 SNN 侧为 ReLU 输出选择 channel-wise 阈值,不训练 或修改输入 ANN 参数。 参考文献:Bu T, Li M, Yu Z. Inference-Scale Complexity in ANN-SNN Conversion for High-Performance and Low-Power Applications. arXiv:2409.03368, 2024. Accepted by CVPR 2025. :param dataloader: 校准数据加载器。 :type dataloader: Iterable :param time_steps: SNN 仿真步数,保留用于 recipe 配置记录。 :type time_steps: int :param mode: 保留用于兼容 RateCodingRecipe 风格的配置校验。 :type mode: str or float :param channel_dim: ReLU 输出的通道维。 :type channel_dim: int :param threshold_candidates: 保留用于兼容早期实验 API;当前实现遵循 原始 LTB 的逐 batch 阈值平衡更新。 :type threshold_candidates: Tuple[float, ...] :param fuse_flag: 是否执行 Conv-BN 融合。 :type fuse_flag: bool :param eps: 数值下界。 :type eps: float ---- .. _LocalThresholdBalancingRecipe.__init__-en: * **English** Construct a training-free local-threshold-balancing ANN2SNN conversion recipe. It uses calibration data only to choose channel-wise thresholds on the SNN side for ReLU outputs, without training or mutating the input ANN parameters. Reference: Bu T, Li M, Yu Z. Inference-Scale Complexity in ANN-SNN Conversion for High-Performance and Low-Power Applications. arXiv:2409.03368, 2024. Accepted by CVPR 2025. :param dataloader: Calibration dataloader. :type dataloader: Iterable :param time_steps: SNN simulation steps, retained as recipe configuration metadata. :type time_steps: int :param mode: Retained for configuration validation in the style of RateCodingRecipe. :type mode: str or float :param channel_dim: Channel dimension of ReLU outputs. :type channel_dim: int :param threshold_candidates: Retained for compatibility with earlier experimental APIs. The implementation follows the original LTB per-batch threshold-balancing update. :type threshold_candidates: Tuple[float, ...] :param fuse_flag: Whether to fuse Conv-BN modules. :type fuse_flag: bool :param eps: Numeric lower bound. :type eps: float """ self.dataloader = dataloader self.time_steps = time_steps self.mode = mode self.channel_dim = channel_dim self.threshold_candidates = tuple(float(v) for v in threshold_candidates) self.fuse_flag = fuse_flag self.eps = eps self.rule = LocalThresholdBalancingReLURule(channel_dim=channel_dim)
[文档] def validate(self, converter: "Converter") -> None: if self.dataloader is None: raise ValueError("LocalThresholdBalancingRecipe requires a dataloader.") if not isinstance(self.time_steps, int) or self.time_steps <= 0: raise ValueError("time_steps must be a positive int.") if not isinstance(self.channel_dim, int): raise ValueError("channel_dim must be int.") if not self.threshold_candidates: raise ValueError("threshold_candidates must not be empty.") if any(v <= 0 or not math.isfinite(v) for v in self.threshold_candidates): raise ValueError( "threshold_candidates must contain finite positive values." ) if self.eps <= 0: raise ValueError("eps must be positive.") validate_rate_coding_mode(self.mode)
[文档] def before_trace(self, converter: "Converter", ann: nn.Module) -> nn.Module: ann.eval() return ann
[文档] def after_trace( self, converter: "Converter", fx_model: fx.GraphModule ) -> fx.GraphModule: return RateCodingRecipe._fuse(fx_model, fuse_flag=self.fuse_flag).to( converter.device )
[文档] def insert_observers( self, converter: "Converter", fx_model: fx.GraphModule ) -> fx.GraphModule: hook_factory = LocalThresholdBalancingHookFactory( mode=self.mode, channel_dim=self.channel_dim, threshold_candidates=self.threshold_candidates, time_steps=self.time_steps, eps=self.eps, ) hook_counts_per_prefix: Dict[str, int] = {} modules = dict(fx_model.named_modules()) for node in list(fx_model.graph.nodes): if node.op != "call_module": continue if node.target not in modules: continue if self.rule.match(node, modules): self.rule.insert_hooks( fx_model, node, hook_factory, hook_counts_per_prefix ) modules = dict(fx_model.named_modules()) fx_model.graph.lint() fx_model.recompile() return fx_model.to(converter.device)
[文档] def calibrate( self, converter: "Converter", fx_model: fx.GraphModule ) -> fx.GraphModule: with torch.no_grad(): for data in tqdm(self.dataloader): fx_model(self._move_batch_to_device(data, converter.device)) return fx_model
[文档] def replace( self, converter: "Converter", fx_model: fx.GraphModule ) -> fx.GraphModule: replaced_hooks = set() replaced_activations = set() modules = dict(fx_model.named_modules()) for activation_node, hook_node in self.rule.find_replacements( fx_model, modules ): if hook_node in replaced_hooks or activation_node in replaced_activations: continue replaced_hooks.add(hook_node) replaced_activations.add(activation_node) self.rule.replace_with_neurons(fx_model, activation_node, hook_node) fx_model.graph.lint() fx_model.delete_all_unused_submodules() fx_model.recompile() fx_model = adapt_step_mode_graph( fx_model, context="LocalThresholdBalancingRecipe step-mode backend", wrap_module_types=_RATE_CODING_STATELESS_MODULE_TYPES, safe_module_types=_RATE_CODING_SAFE_MODULE_TYPES, safe_call_functions=(F.dropout,), ) return fx_model.to(converter.device)
@staticmethod def _move_batch_to_device(data, device: torch.device): imgs = RateCodingRecipe._extract_batch_input(data) if isinstance(imgs, torch.Tensor): return imgs.to(device=device) if isinstance(imgs, (list, tuple, dict)): raise ValueError( "LocalThresholdBalancingRecipe supports single-input calibration " "batches only." ) return torch.as_tensor(imgs, device=device)