Skip to content

NWAVE Tutorial 2: H1v2 Model

This tutorial explores the H1v2 neuromorphic hardware model, comparing its dynamics against the software LIF baseline established in Tutorial 1 and the H1v2 neuromorphic hardware model.

By the end of this tutorial you will have:

  1. Observed how H1v2 membrane and spiking dynamics differ from LIF under identical inputs
  2. Understood which parameters are hardware-fixed and which remain configurable
  3. Seen how fabrication variability (ileak_mismatch) spreads neuron responses
  4. Applied SignAnnealing to enforce the H1v2 block-of-5 sign constraint during training

1. Setup

import random
import numpy as np
import torch
import torch.nn as nn
import matplotlib.pyplot as plt

from nwavesdk.layers import (
    LIFSynapse,
    LIFLayer,
    H1v1Synapse,
    H1v1Layer,
    H1v2Synapse,
    H1v2Layer,
    prepare_net,
)

# Reproducibility
torch.manual_seed(7)
np.random.seed(7)
random.seed(7)
nwavesdk version: 1.0.0a0+cu


/opt/conda/envs/PyTorch/lib/python3.10/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
2026-04-28 10:14:32,516 INFO util.py:154 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.
2026-04-28 10:14:32,765 INFO util.py:154 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.

2. LIF vs H1v2: What Is Configurable?

Feature LIFLayer / LIFSynapse H1v2Layer / H1v2Synapse
Threshold User-set per layer; learn_thresholds=True supported Hardware-fixed; no constructor argument
Reset rule "zero" / "subtraction" / "none" Fixed (subtraction + non-negative clamp)
Tau Any float; learn_taus=True supported Snaps to hardware profiles
Weight range Unbounded [-1.66, 1.66]
Synapse mismatch (stddev) Not modelled Not available — H1v2 mismatch is layer-only (ileak_mismatch)

The code cells below demonstrate each constraint directly.

H1v2-specific constraints (not present in H1v1):
  • No synapse stddevH1v1Synapse accepts a stddev parameter for per-weight Gaussian noise; H1v2Synapse does not.
  • Wider weight range — H1v1 uses [-0.9, 0.9]; H1v2 uses [-1.66, 1.66].
  • Lower synaptic gain — each unit of synaptic weight produces a smaller membrane increment in H1v2 than in H1v1, due to the hardware's linear current model (dV = fo · w). This is why H1v2 networks need larger weights or more inputs to drive neurons to threshold.
# LIF accepts explicit thresholds
lif_ok = LIFLayer(
    n_neurons=1,
    taus=20e-3,
    thresholds=0.25,
    reset_mechanism="subtraction",
    dt=1e-3,
    device="cpu",
)
print("LIF threshold set to:", float(lif_ok.thresholds.item()))

# H1v2: trying to set thresholds should fail at API level
try:
    _ = H1v2Layer(n_neurons=1, taus=20e-3, dt=1e-3, thresholds=0.25)
except TypeError as e:
    print("H1v2Layer rejects custom `thresholds`:")
    print("  ", e)

# H1v2 synapse: no stddev mismatch argument in H1v2 API
try:
    _ = H1v2Synapse(nb_inputs=1, nb_outputs=1, stddev=0.02)
except TypeError as e:
    print("H1v2Synapse rejects `stddev` in constructor:")
    print("  ", e)
LIF threshold set to: 0.25
H1v2Layer rejects custom `thresholds`:
   __init__() got an unexpected keyword argument 'thresholds'
H1v2Synapse rejects `stddev` in constructor:
   __init__() got an unexpected keyword argument 'stddev'

3. Helper Functions

def make_spike_train(T=80, spike_times=(10,)):
    x = torch.zeros(1, T, 1, dtype=torch.float32)
    for t in spike_times:
        x[:, t, 0] = 1.0
    return x


def simulate_stack(syn, layer, x):
    """Run a single synapse->layer stack over time."""
    # Prepare/reset all internal runtime buffers
    bundle = nn.ModuleList([syn, layer])
    prepare_net(bundle)

    cur_trace, spk_trace, mem_trace = [], [], []

    for t in range(x.shape[1]):
        cur = syn(x[:, t, :])
        spk, mem = layer(cur)

        cur_trace.append(cur.detach().cpu())
        spk_trace.append(spk.detach().cpu())
        mem_trace.append(mem.detach().cpu())

    return (
        torch.stack(cur_trace, dim=1),
        torch.stack(spk_trace, dim=1),
        torch.stack(mem_trace, dim=1),
    )  # all tensors [B, T, N]

4. Single Input Spike at Parity of Input and Tau

All models receive the same spike train, nominal tau, and synapse weight. Despite identical inputs, responses diverge because:

  • Membrane jump: H1v2 synaptic output is hardware-mapped with a lower gain constant than both LIF and H1v1. The same nominal weight produces a smaller membrane increment in H1v2 than in H1v1, and a smaller increment still compared to LIF (where weight directly equals membrane jump).
  • Threshold: H1v2's hardware threshold (_vt) is higher than H1v1's, requiring more accumulated charge to fire.
  • Decay law: both H1v1 and H1v2 use linear decay (not exponential)

H1v2 therefore needs more input spikes to reach threshold than H1v1 under the same nominal weight, and more still than LIF.

dt = 1e-3
tau = 10e-3
lif_threshold = 1.0

x = make_spike_train(T=20, spike_times=(5,))

# LIF stack
lif_syn = LIFSynapse(1, 1, device="cpu")
lif_layer = LIFLayer(
    n_neurons=1,
    taus=tau,
    thresholds=1,
    reset_mechanism="subtraction",
    dt=dt,
    device="cpu",
)

# H1v1 stack
h1_syn = H1v1Synapse(1, 1, device="cpu", lif_threshold=lif_threshold)
h1_layer = H1v1Layer(
    n_neurons=1,
    taus=tau,
    dt=dt,
    ileak_mismatch=False,
    device="cpu",
)


# H1v2 stack
h2_syn = H1v2Synapse(1, 1, device="cpu", lif_threshold=lif_threshold)
h2_layer = H1v2Layer(
    n_neurons=1,
    taus=tau,
    dt=dt,
    ileak_mismatch=False,
    device="cpu",
)

# Force same visible initialization for both synapses
with torch.no_grad():
    lif_syn.weight.fill_(0.9)
    h1_syn.weight.fill_(0.9)
    h2_syn.weight.fill_(0.9)

cur_lif, spk_lif, mem_lif = simulate_stack(lif_syn, lif_layer, x)
cur_hwv1, spk_hwv1, mem_hwv1 = simulate_stack(h1_syn, h1_layer, x)
cur_hwv2, spk_hwv2, mem_hwv2 = simulate_stack(h2_syn, h2_layer, x)

print(
    f"Peak synaptic output: LIF={cur_lif.max():.4f}, H1v1={cur_hwv1.max():.4f}, H1v2={cur_hwv2.max():.4f}"
)
print(
    f"Total spikes:         LIF={spk_lif.sum():.0f}, H1v1={spk_hwv1.sum():.0f}, H1v2={spk_hwv2.sum():.0f}"
)
print(
    f"Peak membrane:        LIF={mem_lif.max():.4f}, H1v1={mem_hwv1.max():.4f}, H1v2={mem_hwv2.max():.4f}"
)


def plot_lif_vs_h2(
    x,
    cur_lif,
    spk_lif,
    mem_lif,
    cur_hwv1,
    spk_hwv1,
    mem_hwv1,
    cur_hwv2,
    spk_hwv2,
    mem_hwv2,
):
    (
        cur_lif,
        spk_lif,
        mem_lif,
        cur_hwv1,
        spk_hwv1,
        mem_hwv1,
        cur_hwv2,
        spk_hwv2,
        mem_hwv2,
    ) = (
        cur_lif[0, :, 0],
        spk_lif[0, :, 0],
        mem_lif[0, :, 0],
        cur_hwv1[0, :, 0],
        spk_hwv1[0, :, 0],
        mem_hwv1[0, :, 0],
        cur_hwv2[0, :, 0],
        spk_hwv2[0, :, 0],
        mem_hwv2[0, :, 0],
    )  # let's look at the 0-th batch and the  0-th neuron
    t = np.arange(x.shape[1])
    fig, ax = plt.subplots(4, 1, figsize=(10, 10), sharex=True)

    ax[0].stem(t, x[0, :, 0].numpy(), basefmt=" ", linefmt="k-", markerfmt="ko")
    ax[0].set_ylabel("Input")
    ax[0].set_title("Single input spike")

    ax[1].plot(t, cur_lif, label="LIF synapse output", lw=2)
    ax[1].plot(t, cur_hwv1, label="H1v1 synapse output", lw=2)
    ax[1].plot(t, cur_hwv2, label="H1v2 synapse output", lw=2)
    ax[1].set_ylabel("Syn output")
    ax[1].legend()

    ax[2].plot(t, mem_lif, label="LIF membrane", lw=2)
    ax[2].plot(t, mem_hwv1, label="H1v1 membrane", lw=2)
    ax[2].plot(t, mem_hwv2, label="H1v2 membrane", lw=2)
    ax[2].axhline(
        lif_threshold, ls="--", color="tab:blue", alpha=0.3, label="LIF threshold"
    )
    ax[2].axhline(0.204, ls="--", color="tab:orange", alpha=0.3, label="H1v1 threshold")
    ax[2].axhline(0.322, ls="--", color="tab:green", alpha=0.3, label="H1v2 threshold")
    ax[2].set_ylabel("Membrane")
    ax[2].legend()

    ax[3].plot(t, spk_lif, label="LIF spikes", lw=2)
    ax[3].plot(t, spk_hwv1, label="H1v1 spikes", lw=2)
    ax[3].plot(t, spk_hwv2, label="H1v2 spikes", lw=2)
    ax[3].set_ylabel("Spikes")
    ax[3].set_xlabel("Time step")
    ax[3].legend()

    plt.tight_layout()
    plt.show()


plot_lif_vs_h2(
    x,
    cur_lif,
    spk_lif,
    mem_lif,
    cur_hwv1,
    spk_hwv1,
    mem_hwv1,
    cur_hwv2,
    spk_hwv2,
    mem_hwv2,
)
Peak synaptic output: LIF=0.9000, H1v1=19.7304, H1v2=0.0786
Total spikes:         LIF=0, H1v1=0, H1v2=0
Peak membrane:        LIF=0.9000, H1v1=0.1973, H1v2=0.0786

png

# Numeric comparison: threshold and synaptic gain for H1v1 vs H1v2
h1_ref = H1v1Layer(n_neurons=1, taus=tau, dt=dt, device="cpu")
h2_ref = H1v2Layer(n_neurons=1, taus=tau, dt=dt, device="cpu")
prepare_net(h1_ref)
prepare_net(h2_ref)

# Set weights before prepare_net, as this function call is needed before inference/training but always after parameter changes
h1_syn_ref = H1v1Synapse(1, 1, device="cpu")
h2_syn_ref = H1v2Synapse(1, 1, device="cpu")
with torch.no_grad():
    h1_syn_ref.weight[0] = 1
    h2_syn_ref.weight[0] = 1

prepare_net(h1_syn_ref)
prepare_net(h2_syn_ref)

x_one = torch.ones(1, 1)
h1_cur = h1_syn_ref(x_one).item()
h2_cur = h2_syn_ref(x_one).item()

print("--- Hardware threshold (_vt) ---")
print(f"  H1v1: {float(h1_ref._vt.mean()):.4f} V")
print(f"  H1v2: {float(h2_ref._vt.mean()):.4f} V")
print()
print("--- Synaptic current for weight=1, one spike ---")
print(f"  LIF : 1.0000  (weight maps directly to membrane jump)")
print(f"  H1v1: {h1_cur:.4f}")
print(f"  H1v2: {h2_cur:.4f}")
print()
print(f"Spikes needed to reach threshold (weight=1, no decay):")
print(f"  LIF : {1.0 / 1.0:.5f}")
print(f"  H1v1: {float(h1_ref._vt.mean()) / h1_cur:.5f}")
print(f"  H1v2: {float(h2_ref._vt.mean()) / h2_cur:.5f}")
--- Hardware threshold (_vt) ---
  H1v1: 0.2040 V
  H1v2: 0.3226 V

--- Synaptic current for weight=1, one spike ---
  LIF : 1.0000  (weight maps directly to membrane jump)
  H1v1: 21.3811
  H1v2: 0.0873

Spikes needed to reach threshold (weight=1, no decay):
  LIF : 1.00000
  H1v1: 0.00954
  H1v2: 3.69324

Interpretation

The three models behave differently even at identical weight and nominal tau:

LIF H1v1 H1v2
Membrane jump per spike (weight=1) 1.0 (exact) hardware-mapped, < 1 hardware-mapped, < H1v1
Threshold User-set (1.0 here) ~0.2 V (chip-level) higher than H1v1 (chip-level)
Spikes to threshold (weight=1) fewest more than LIF more than H1v1

H1v2's lower synaptic gain is networks often need more input channels or higher weights than their H1v1 equivalents.

The next experiment increases the number of input spikes to drive the H1v2 model to threshold.

x = make_spike_train(T=20, spike_times=(10, 11, 12))


cur_lif, spk_lif, mem_lif = simulate_stack(lif_syn, lif_layer, x)
cur_hwv1, spk_hwv1, mem_hwv1 = simulate_stack(h1_syn, h1_layer, x)
cur_hwv2, spk_hwv2, mem_hwv2 = simulate_stack(h2_syn, h2_layer, x)

plot_lif_vs_h2(
    x,
    cur_lif,
    spk_lif,
    mem_lif,
    cur_hwv1,
    spk_hwv1,
    mem_hwv1,
    cur_hwv2,
    spk_hwv2,
    mem_hwv2,
)

png

Note: The membrane trace does not visually cross threshold because the reset is instantaneous — the spike and reset occur within the same timestep and the plot samples post-reset membrane values.

5. H1v2 Non-Idealities: Threshold and Leak Variability

H1v2 mismatch is layer-only: ileak_mismatch=True adds per-neuron variability in the leak current, spreading membrane trajectories across the population. Unlike H1v1, H1v2 has no stddev parameter on the synapse — weight-level noise is absent.

We compare ileak_mismatch=False vs ileak_mismatch=True on a population of 40 neurons with identical weights:

  • Without mismatch, neurons overlap in response.
  • With mismatch, membrane trajectories and spike counts diverge.
def run_h2_population(ileak_mismatch, n_neurons=40, tau=20e-3, w=1.4, seed=7):
    torch.manual_seed(seed)

    syn = H1v2Synapse(1, n_neurons, device="cpu", lif_threshold=1.0)
    layer = H1v2Layer(
        n_neurons=n_neurons,
        taus=tau,
        dt=1e-3,
        ileak_mismatch=ileak_mismatch,
        device="cpu",
    )

    with torch.no_grad():
        syn.weight.fill_(w)

    _, spk_hw, mem_hw = simulate_stack(syn, layer, x_multi)
    return mem_hw, spk_hw


x_multi = make_spike_train(T=25, spike_times=(5, 7, 17, 19))

mem_nom, spk_nom = run_h2_population(ileak_mismatch=False)
mem_mis, spk_mis = run_h2_population(ileak_mismatch=True)

spk_count_nom = (spk_nom > 0.5).sum(axis=0)
spk_count_mis = (spk_mis > 0.5).sum(axis=0)

fig, ax = plt.subplots(1, 2, figsize=(15, 4))

for n in range(10):
    ax[0].plot(
        mem_nom[0, :, n], alpha=0.7, lw=1.5
    )  # plot neurons over the 0-th batch input
ax[0].set_title("H1v2 mem, ileak_mismatch=False")
ax[0].set_xlabel("Time step")
ax[0].set_ylabel("Membrane")

for n in range(10):
    ax[1].plot(
        mem_mis[0, :, n], alpha=0.7, lw=1.5
    )  # plot neurons over the 0-th batch input
ax[1].set_title("H1v2 mem, ileak_mismatch=True")
ax[1].set_xlabel("Time step")


plt.tight_layout()
plt.show()

png

6. Tau Request vs Effective Tau

With the same user-requested tau, LIF keeps that value directly, while H1v2 may snap tau to supported hardware profiles.

tau_requested = 17e-3

lif_tau = LIFLayer(
    n_neurons=1,
    taus=tau_requested,
    thresholds=0.3,
    reset_mechanism="subtraction",
    dt=1e-3,
    device="cpu",
)

h2_tau = H1v2Layer(
    n_neurons=1,
    taus=tau_requested,
    dt=1e-3,
    ileak_mismatch=False,
    device="cpu",
)

print(f"Requested tau: {tau_requested * 1e3:.3f} ms")
print(f"LIF effective tau: {float(lif_tau.taus.item()) * 1e3:.3f} ms")
print(f"H1v2 effective tau:  {float(h2_tau.taus.item()) * 1e3:.3f} ms")
Requested tau: 17.000 ms
LIF effective tau: 17.000 ms
H1v2 effective tau:  13.820 ms

7. Weight constraints and block-of-5 sign topology

The sign-topology constraint is shared with H1v1 (see Tutorial 1 Section 4f). The mechanism is identical — the analog routing fabric groups every 5 inputs per output neuron into a block, and all weights in a block must share the same sign. The key difference is the weight range:

H1v1 H1v2
Weight range [-0.9, 0.9] [-1.66, 1.66]
Sign topology Block of 5 Block of 5

SignAnnealing enforces the sign constraint during training by progressively hardening block-level sign agreement while keeping gradient flow through magnitudes. The demonstrative training run below uses H1v2 weights.

from nwavesdk.optim import SignAnnealing


class ToyH2Net(nn.Module):
    def __init__(self, n_in, n_out=2):
        super().__init__()
        self.syn = H1v2Synapse(
            n_in,
            n_out,
        )

    def forward(self, x):
        T = x.shape[1]
        trace = []
        prepare_net(self)

        for t in range(T):
            cur_hidden = self.syn(x[:, t, :])
            trace.append(cur_hidden)

        return torch.stack(trace, dim=1)
# Fake dataset (random inputs/targets): this is only to demonstrate the topology behavior
n_in, n_out = 12, 25
x_fake = torch.randn(1, 128, n_in)
y_fake = torch.randn(1, 128, n_out)

net = ToyH2Net(n_in, n_out)

W_unconstrained = net.syn.weight.detach().clone()

epochs = 10
sign_annealer = SignAnnealing(net, total_epochs=epochs, alpha_start=0.5, alpha_end=12.0)
optimizer = torch.optim.Adam(net.parameters(), lr=5e-2)

loss_hist = []

for epoch in range(1, epochs + 1):
    sign_annealer.step(net, epoch)

    pred = net(x_fake)
    loss = torch.mean((pred - y_fake) ** 2)

    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

    with torch.no_grad():
        loss_hist.append(float(loss.item()))

W_final = net.syn.weight.detach().cpu()
fig, ax = plt.subplots(1, 2, figsize=(16, 4))

ax[0].imshow((W_unconstrained.cpu().numpy() > 0), aspect="auto", cmap="gray_r")
ax[0].set_title("Before SignAnnealing")
ax[0].set_xlabel("Output column")
ax[0].set_ylabel("Input row")

ax[1].imshow((W_final.numpy() > 0), aspect="auto", cmap="gray_r")
ax[1].set_title("After SignAnnealing (W > 0)")
ax[1].set_xlabel("Output column")

for c in range(5, n_out, 5):
    ax[1].axvline(c - 0.5, color="red", lw=0.8, alpha=0.6)

plt.tight_layout()
plt.show()

png

8. Summary

Parameter / Feature LIFLayer H1v2Layer
Tau (τ) Any float; learn_taus=True stores _d_taus as nn.Parameter Set at init → _Ileak; not a gradient-learnable parameter
Threshold (θ) Per-layer; learn_thresholds=True makes it learnable Fixed chip-wide; same for all layers; not learnable
Reset mechanism subtraction / zero / none Fixed (subtraction + non-negative clamp)
Membrane polarity Can go negative Always >= 0 (hardware clamp)
Weight range Unbounded Bounded to [-1.66, 1.66] (H1v1 uses [-0.9, 0.9])
Sign topology No constraint Groups of 5 weights must share sign (shared with H1v1)
Synapse mismatch (stddev) Not modelled Not available — H1v1Synapse supports stddev; H1v2Synapse does not
Layer variability Not modelled ileak_mismatch=True (also present in H1v1)
Primary use case Research, prototyping Hardware deployment on Neuronova H1v2

Next Steps

Tutorial 3 covers CPU/GPU execution: how to port H1v1 and H1v2 networks to GPU, verify numerical equivalence, and benchmark inference and training throughput.