Training¶
A small convolutional neural network is trained for each supported circuit
to give initial guesses in Circuit.fit(), to (hopefully) converge reliably
and quickly without any manual initial guess.
fasteis comes bundled with pre-trained models. The fasteis PyPI package
does not come with all the training infrastructure. Training new circuits
requires a clone of the repository, and extra dependencies like torch.
Trained models and their benchmark results are listed under Models.
Model¶
All models use a 1D convolutional neural network over log-frequency, starting with a 3x64 matrix of normalised |Z|, phase, and frequency.
Shifting a time constant translates features in frequency, so 1D convolution along the frequency axis works well (translation equivariance inductive bias).
A stem convolution maps the 3 input channels to to x 'feature' channels,
followed by four residual blocks with dilations ½/4/8, then mean+max pooling
over the frequency axis to get a 2x length vector (plus 2 scaling constants).
Then a 3-layer 'head' of width y, then outputs a mean and
log-variance per parameter.
model.Config sets the widths x and y and is stored in the checkpoint. The
width is trade-off between model speed and accuracy. rc uses 16 / 64, the two
sei_randles circuits use 64 / 256, and the rest 32 / 128.
| channels / head | weights | file | inference |
|---|---|---|---|
| 16 / 64 | 17.7k | 36 KiB | 0.23 ms |
| 32 / 128 | 68.6k | 136 KiB | 0.70 ms |
| 64 / 256 | 269k | 529 KiB | 2.41 ms |
Input¶
The input frequencies and impedances are resampled onto 64 log-spaced points
across the measured range. There are three arrays in the input: log10|Z_hat|,
phase/(pi/2), log10(w_hat)/4, and two scalars: sweep width in decades and
point count.
Scaling symmetries¶
The frequencies, impedance, and the parameters are all renormalised so the model only needs to learn the curve shape, and not the scale.
Impedance is invariant under Z -> k*Z and w -> w/w_c when parameters are
transformed to match.
Each parameter picks up the scales as
with i = -1 when the exponent has no parameter dependence. Each
TrainingCircuit has a scaling attribute with one (a, b, c, i) row per
parameter, which is written into the weight file so the Rust reader needs no
per-circuit code. Resistances are (1, 0, 0, -1), capacitances
(-1, -1, 0, -1), inductances (1, -1, 0, -1), time constants
(0, -1, 0, -1), and a CPE q is (-1, 0, -1, alpha_index).
Choosing k and w_c¶
Several methods have been tried, with reactive_centroid seeming the best.
Here, each point is weighted by max(-sin(phase), 0), bounded in [0,1]
and scale-free, then takes log-space weighted means of |Z| and w. This means
featureless parts of the curve carry little weight, so widening the sweep beyond
the features does not move the scaling estimates.
See compare_scales.py for details of the comparison. The numbers here are how
much the target (scaled) parameters shift between different conditions. A good
scale estimator minimises all three of these:
| estimator | across systems | across sweeps | across noise |
|---|---|---|---|
| none (control) | 1.7 – 2.3 | — | — |
window |
0.44 – 0.77 | 0.19 – 0.36 | 0.003 |
reactive_centroid |
0.42 – 0.74 | 0.13 – 0.27 | 0.006 |
imag_weighted |
0.45 – 0.76 | 0.19 – 0.42 | 0.004 |
imag_peak |
0.54 – 1.15 | 0.22 – 0.52 | 0.030 |
Synthetic training data¶
See priors.py.
Training data is generated only where features of the circuit are observable,
otherwise it is impossible to guess parameters. Overall impedance and frequency
scale are randomised to exercise the normalisation. Noise is proportional to
|Z|, and log-uniform over 0.2%–5%.
Point dropout, outliers and a series inductance term exist behind flags in
PriorConfig, all off by default.
Loss¶
The residual is the same as the fit - a modulus-weighted residual of the guessed curve vs observed.
The negative log-likelihood (NLL) term keeps gradient available where the
residual has plateaus. E.g. a time constant several decades off gives a curve
with no observable arc in the window, where d(residual)/d(log tau) vanishes -
and keeps the log-variance head trained. It never decays to zero, so an
out-of-range alpha always has a gradient pulling it back.
Storing the model¶
The model is stored in src/models/*.eisnn, written by serialize_weights.py.
The weights themselves are 99.8% of the bytes, so changing the dtype reduces size at the cost of accuracy. For randles, dropping to f16 is reasonable:
| dtype | file | init params error | converged | excess med | p99 |
|---|---|---|---|---|---|
| f32 | 270 KiB | 1.8% | 100% | 1 | 7 |
| f16 (default) | 136 KiB | 1.8% | 100% | 1 | 7 |
| int8 | 69 KiB | 3.6% | 100% | 1 | 9 |
Adding a circuit¶
- Clone the repo:
git clone https://github.com/empaeconversion/fasteis - Install the package with dev dependencies
uv sync - Create a
TrainingCircuitclass for the circuit incircuits.pyand add it to the registry - Check it is identifiable with
training/inspect_priors.py <name> - Train with e.g.
training/train.py --circuit <name> --steps 10000 --batch 4096 --workers 12 - Export with
training/export.py --circuit <name>, which writessrc/models/<name>.eisnn -
Add a row to
MODELSinsrc/models.rsand rebuild(optional, for devs)
-
Benchmark with
training/benchmark.py --circuit <name> --n 2000 - Add a page for it under
docs/models/and list it inzensical.toml's nav - Regenerate the tables with
training/update_docs.py - Regenerate the circuit diagrams with
training/generate_diagrams.py