English | 中文
Paper.
CuSPaccompanies A synergistic spectropolarimetric inversion via gradient-bias annealing and physics-informed neural networks, by G. Y. Chen, Y. Guo, C. J. Díaz Baso, Q. Hao & M. D. Ding, A&A 710, A227 (2026) — DOI 10.1051/0004-6361/202659270 (open access, CC BY 4.0). Please cite it if you use this code; a ready-to-paste BibTeX entry is in section 10.
CuSP is a PyTorch toolkit for the Milne–Eddington (ME) inversion of solar
spectropolarimetric data. It provides a fully differentiable forward model of
the Stokes vector IQUV, batched inversion on GPU/CPU (CMA-ES, generalized
simulated annealing, Levenberg–Marquardt), fast Voigt line profiles, and a
physics-informed neural network (PI2NN) that can be used to hand the inversion
a good initial guess instead of starting from random noise.
import torch
from CuSP import MEInversion
inv = MEInversion(torch.tensor(wavebands), landeG=2.5, lambda0=630.25, wing=wing)
params = inv(iquv_obs) # random start
params = inv(iquv_obs, initial_guess='sdo_hmi') # PI2NN initial guess- Features
- Installation
- Repository layout
- The eight ME parameters
- Quick start
- Initial guesses
- Line profiles
- Module reference
- Notes, caveats and known issues
- Citation
| Feature | Module | Notes |
|---|---|---|
ME forward synthesis of Stokes IQUV |
me_forward.py |
analytic Unno–Rachkovsky solution, fully vectorised, differentiable, device-agnostic |
| ME inversion (batched, four interchangeable methods) | me_inversion.py |
method='cmaes' (CMA-ES, default), 'annealing' (generalized SA with a gradient-biased step selection, i.e. the GBA algorithm of the paper), 'lm' (Levenberg–Marquardt) or 'csa' (legacy conjugate SA); GPU-supported |
| Fast Voigt / Faraday–Voigt profiles | voigt.py |
7/7 complex rational approximation of the Faddeeva function — no numerical quadrature, accurate for small and large damping alike |
| Vectorised response functions (Jacobians) | me_rf.py |
element-wise (pixel × wavelength) autodiff: the response functions of all four Stokes components in one backward pass each, so the LM Jacobian no longer scales with the number of wavelength samples |
| Learned initial guesses | initial_guess.py |
shipped PI2NN network for SDO/HMI Fe I 6173 Å spectra: initial_guess='sdo_hmi' |
| Physics-informed neural network inversion | PI2NN.py |
InversionNet (conv + attention + residual FC) with a physics loss, plus a training loop |
| Example notebooks | src/CuSP/examples/ |
forward.ipynb, inversion.ipynb |
git clone git@git.nju.edu.cn:gychen/CuSP.git
cd CuSP
pip install -e .Requirements (see setup.py): torch>=1.13.1, numpy>=1.24.3,
matplotlib>=3.7.2, scipy>=1.11.3.
Without installing, add the source directory to the path:
import sys; sys.path.insert(0, "CuSP/src")All computation runs on CPU; a CUDA device is used automatically when available
(MEInversion(..., device='cuda'), or simply keep the tensors on the GPU).
CuSP/
├── setup.py
└── src/CuSP/
├── __init__.py # public API: MEForward, MEInversion, PI2NN helpers
├── me_forward.py # MEForward : Stokes IQUV forward synthesis
├── me_inversion.py # MEInversion : batched ME inversion (+ CudaAnnealing)
├── annealing.py # GSA / DualAnnealing building blocks
├── lm.py # BatchLM : batched Levenberg-Marquardt
├── cmaes.py # BatchCMAES : batched CMA-ES
├── me_inverters.py # binds BatchLM / BatchCMAES to the ME problem
├── me_rf.py # vectorised response functions (LM Jacobian)
├── voigt.py # fast Voigt & Faraday–Voigt profiles
├── initial_guess.py # PI2NN initial guesses (load / register / evaluate)
├── PI2NN.py # physics-informed neural network inversion
├── data/
│ └── pi2nn_sdo_hmi.pkl # trained PI2NN weights for SDO/HMI
└── examples/
├── forward.ipynb # forward-model tutorial
├── inversion.ipynb # inversion tutorial
├── inversion_random_cmaes_lm.py # random start -> CMA-ES -> LM
└── inversion_pi2nn_lm.py # PI2NN start -> LM
MEInversion works in a normalised 8-D box [0,1]^8; the physical parameters
are recovered with inv.denormalizing_parameter(x). The order is fixed and
shared by the forward model, the inversion and PI2NN:
| # | Parameter | Meaning | Unit | Range | Normalised |
|---|---|---|---|---|---|
| 1 | Dlambda_D |
Doppler width (thermal + microturbulence) | nm (1e-4 … 5e-2 nm = 0.1 … 50 mÅ) |
v_D_range = [1, 500] × 1e-4 nm |
log |
| 2 | v_los |
line-of-sight velocity | m/s | -7000 … 7000 |
linear |
| 3 | eta_0 |
ratio of line to continuum absorption | — | 1 … 1000 |
log |
| 4 | S10 |
source-function ratio S1/S0 |
— | 0.1 … 10 |
log |
| 5 | a_damp |
Lorentz damping constant a of the Voigt profile |
— | 0.4 … 0.6 |
log |
| 6 | Bmag |
magnetic field strength | G | 5 … 5000 |
log |
| 7 | theta |
field inclination | rad | 0 … π |
linear |
| 8 | phi |
field azimuth | rad | 0 … π |
linear |
Ranges follow Centeno et al. (2014), doi:10.1007/s11207-014-0497-7.
They are class attributes of MEInversion (v_D_range, v_los_range, …) and can
be overridden in a subclass.
PI2NNuses the same physical ranges but scalesa_dampandBmaglinearly instead of logarithmically.initial_guess.pyconverts between the two conventions through the physical values, so mixing them up is not an issue as long as you go through the provided API.
MEForward turns ME parameters into a continuum-normalised Stokes vector.
Both the observed wavebands and a wing reference wavelength must be given;
the wing point is prepended internally and used to normalise the spectrum, so
the returned profiles correspond exactly to the wavebands you passed in.
import numpy as np
import torch
from CuSP import MEForward
lambda0, dlambda, nlambda = 630.25, 4e-4, 100 # nm
ll = lambda0 - 0.5 * dlambda * nlambda + np.arange(nlambda) * dlambda
wing = ll.min() # continuum reference
forward = MEForward(torch.tensor(ll).float(), landeG=2.5, lambda0=lambda0, wing=wing)
B = 8 # number of pixels
p = lambda lo, hi, log=True: ( # uniform / log-uniform sampler
torch.exp(torch.rand(B, 1) * (np.log(hi) - np.log(lo)) + np.log(lo))
if log else torch.rand(B, 1) * (hi - lo) + lo)
Dlambda_D, v_los, eta_0, S10 = p(1e-4, 5e-2), p(-7e3, 7e3, False), p(1, 1e3), p(0.1, 10)
a_damp, Bmag, theta, phi = p(0.4, 0.6, False), p(5, 5e3), p(0, np.pi, False), p(0, np.pi, False)
I, Q, U, V = forward(Dlambda_D, v_los, eta_0, S10, a_damp, Bmag, theta, phi) # (B, N) each
iquv = torch.stack([I, Q, U, V], dim=1) # (B, 4, N)Every input must broadcast to the same shape (typically (B, 1)), and the call
returns a single (4, B, N) tensor that unpacks into I, Q, U, V.
Four interchangeable optimizers work on exactly the same normalised parameter
box [0, 1]^8, accept the same initial_guess and minimise the same weighted
chi2, so they can be compared or swapped with one keyword:
method= |
Optimizer | Character |
|---|---|---|
'cmaes' |
CMA-ES (CuSP.cmaes.BatchCMAES) |
derivative-free global search, no Jacobian needed — the default method |
'annealing' (alias 'gsa') |
generalized simulated annealing with a gradient-biased step selection (DualAnnealing) — the paper's GBA |
stochastic, derivative-free, robust to a poor start |
'lm' |
Levenberg–Marquardt (CuSP.lm.BatchLM) |
local, uses the exact Jacobian obtained by autodiff from the vectorised response functions (me_rf.py, one backward pass per Stokes component); fastest convergence from a good start, needs one |
'csa' |
conjugate simulated annealing (CudaAnnealing, legacy) |
prints a full epoch log |
from CuSP import MEInversion
inv = MEInversion(torch.tensor(lm).float(), landeG=2.5, lambda0=630.25, wing=wing)
x0 = inv.make_initial_guess(iquv_obs, initial_guess='random') # or 'sdo_hmi', see section 6
# (a) CMA-ES -- the default method, no keyword needed
params = inv(iquv_obs, max_iter=200)
# (b) generalized / gradient-bias annealing from a random start (the paper's GBA)
params = inv(iquv_obs, method='annealing', maxiter=200, initial_temp=5230.)
# (c) Levenberg-Marquardt (local: give it a good starting point)
params = inv(iquv_obs, method='lm', max_iter=60, initial_guess=x0)
# (d) legacy conjugate simulated annealing
params = inv(iquv_obs, method='csa', max_iter=1000)
# (e) start from a PI2NN prediction -- the shipped network is tied to the SDO/HMI
# sampling, so it needs its own inversion object: see section 6.
# params = inv_hmi(iquv_obs_hmi, initial_guess='sdo_hmi', method='lm')Which one to use?
cmaes(the default) is the derivative-free global search: no Jacobian is needed and it is cheap per unit accuracy, so it is the natural first pass over large data sets;annealingis the paper's GBA driver — stochastic, derivative-free and robust to a poor start — but it needsinitial_temptuned to your merit scale;lmis local and by far the most accurate once a good starting point exists, and the vectorised response functions (below) make each of its iterations cheap;csais kept for backwards compatibility.
initial_temp must be matched to the merit scale of your problem (the
chi2/dof returned by merit_function). If it sits far above the values a fit
reaches, nearly every proposal is accepted and the annealer barely moves, so set
it to the order of magnitude of your chi2/dof.
- With a trained network for your line/instrument (e.g.
initial_guess='sdo_hmi') → uselm. It is a local method, so a starting point in the right basin is all it needs, and it is the most accurate of the four. - Without a network (random initial guess) → run
cmaesorannealingfirst for the global search, thenlmto refine:lmalone has no way of finding the basin from a random start on this multi-modal problem.cmaesis the default, so a plaininv(iquv_obs)already is that global stage.
import torch
# --- no network available: global search first, then LM refinement ----------
p1 = inv(iquv_obs, method='cmaes', max_iter=200)
# p1 = inv(iquv_obs, method='annealing', maxiter=100, initial_temp=0.1)
x1 = inv.normalizing_parameter(torch.as_tensor(p1)) # back to the [0, 1]^8 box
p2 = inv(iquv_obs, method='lm', max_iter=60, initial_guess=x1)
# --- a network provides the starting point: LM straight away ----------------
# (the shipped network is tied to its own wavelength setup -- see section 6)
p2 = inv_hmi(iquv_obs_hmi, method='lm', max_iter=60, initial_guess='sdo_hmi')Complete runnable versions of both recipes are in
src/CuSP/examples/inversion_random_cmaes_lm.py and
src/CuSP/examples/inversion_pi2nn_lm.py.
A cmaes/annealing pass followed by lm is therefore the recommended route
whenever no network is available: the global stage is what puts the local
refinement into the right basin. Two notes:
- the global stage must be allowed to explore, so
CuSP.me_inverters.run_cmaesdefaults topop_size=30, bounded=False(a larger population, no logit squashing) instead of the class defaults; cmaesis cheap per unit accuracy, so for millions of pixels it is the natural first pass, withlmadded only where the extra accuracy is needed.
method='lm' needs the derivative of the Stokes vector with respect to the eight
parameters. The ME forward model is element-wise in the pixel and in the
wavelength, so CuSP.me_rf expands the parameters along a wavelength axis, marks
the expanded tensor as an autograd leaf and then takes one backward pass per
Stokes component: the loop over the 4N observables disappears and the cost
stops growing with the wavelength sampling.
from CuSP import VectorizedResponseFunction
rf = VectorizedResponseFunction(inv) # an MEForward or MEInversion instance
rf(x0) # (B, N, 4, 8) = d(Stokes)/d(normalised params)
rf(x0, layout='lm') # (B, 4*N, 8) = the Jacobian BatchLM wantsrun_lm installs it as the default through BatchLM's usrLMcoef hook (so
lm.py itself is untouched); rf_method='loop' restores the previous
loop-over-observables Jacobian.
The gain is structural rather than incremental: the loop method spends 4N + 1
backward passes per LM coefficient evaluation (one per observable, plus the loss
gradient), while the vectorised version always spends 5 — one per Stokes
component plus one for the continuum normalisation — whatever the number of
wavelength samples N. Dense sampling therefore costs no more than the HMI
6-point setup. (Single-pixel work on a GPU stays launch-bound, so use a GPU for
batches rather than for one pixel at a time.)
iquv_obs has shape (B, 4, N) (channels ordered I, Q, U, V, continuum
normalised, i.e. exactly what MEForward returns). The returned params is the
physical (B, 8) parameter array; the normalised start/end points and the merit
values are stored alongside it:
inv.ivs_results.keys() # dict_keys(['x0', 'e0', 'x', 'e'])
# x0 / e0 : denormalised initial guess and its chi2/F
# x / e : inverted parameters and their chi2/FUseful keyword arguments (pass through inv(...)):
| Keyword | Default | Meaning |
|---|---|---|
method |
'cmaes' |
'cmaes', 'annealing' (= 'gsa'), 'lm' or 'csa' |
initial_guess |
None |
see section 6 |
x_guess |
None |
explicit normalised (B,8) start; takes precedence over initial_guess |
maxiter / max_iter |
200 |
iterations: cmaes 200, lm 60; for the annealing methods, steps per temperature (1000) |
initial_temp |
5230. |
starting temperature |
visit, accept, no_local_search |
2.62, -5.0, False |
gsa control parameters |
adam |
{}, i.e. nepoch=1000 |
local Adam refinement run after the annealing; pass adam=dict(nepoch=0) to skip it, or dict(nepoch=2000, learning_rate=1e-3) to tune it |
max_batches |
1e10 |
split large batches into chunks of this size |
rf_method |
'vector' |
how method='lm' builds its Jacobian: 'vector' (me_rf, one backward pass per Stokes component) or 'loop' (one backward pass per observable) |
objective_clamp |
(-1., 2.) |
box the objective is evaluated in before denormalising (see section 9) |
device |
iquv_obs.device |
computation device |
import matplotlib.pyplot as plt
inv.plot_annealing_hist() # cooling history
plt.figure(figsize=(8, 8))
inv.plot_inversion_results(choice_index=0) # observation vs inversion
inv.plot_inversion_results(choice_index=0, params_obs=truth) # + the true model, if knownPI2NN learns the inverse mapping Stokes -> ME parameters and is trained with a
combined data loss and physics loss (the physics term re-synthesises the spectrum
and compares it with the observation).
import torch
from CuSP.PI2NN import PI2NN # note: the class lives inside the PI2NN module
net = PI2NN(hidden_layers=[64] * 2, input_size=6, output_size=8, device='cpu',
use_residual=False,
forward_params=dict(wavebands=lm.tolist(), landeG=2.5,
lambda0=630.25, wing=float(lm.min())))
net.train(max_iter=1000, training_set=dict(total_size=int(1e6)),
batch_size=20000, print_interval=10, save_interval=500,
save_path='./PI2NN_models/', save_name='model',
do_physics_informed=True, weight_physcis=1.0, dense_spectrum=False)
params = net(iquv_obs) # physical parameters, shape (B, 8)input_sizeis the number of wavelength samples; the ConvNet flattens to(input_size - 4) * 32features, so the input tensor is(B, 4, input_size).- If
forward_paramsis given, training data are generated on the fly whentraining_setprovides noinputs. ⚠️ PI2NNoverridesnn.Module.trainwith its training loop, andnn.Module.eval()is implemented asself.train(False)— never callnet.eval()on aPI2NNinstance (it would start training). Usenet.training = False; net.net.eval().
Starting the annealing from a good guess is what makes large HMI-style data sets
tractable. MEInversion.__call__ accepts
initial_guess= |
Behaviour |
|---|---|
None (default) |
uniform random start in [0,1]^8 |
'sdo_hmi' |
evaluate the shipped PI2NN network on iquv_obs |
other str |
a name registered with register_initial_guess, or a path to a .pkl saved by torch.save(PI2NN_instance, path) |
torch.Tensor |
explicit normalised guess, shape (B, 8) or (8,) |
| callable | f(iquv_obs, inversion=self, device=...) returning physical or normalised (B, 8) parameters |
from CuSP import MEInversion, get_initial_guess_model, list_initial_guess_models
print(list_initial_guess_models()) # {'sdo_hmi': 'PI2NN trained on ...'}
ig = get_initial_guess_model('sdo_hmi') # wrapper, with the model's own setup
print(ig.describe())
inv = MEInversion(ig.wavebands, landeG=ig.landeG, lambda0=ig.lambda0, wing=ig.wing)
x0 = inv.make_initial_guess(iquv_obs, initial_guess='sdo_hmi') # normalised (B,8)
phys = ig.predict_physical(iquv_obs) # physical (B,8)
params = inv(iquv_obs, initial_guess='sdo_hmi') # or go straight ahead- Trained for SDO/HMI spectra of Fe I 6173.34 Å: 6 wavelength samples at
617.317139, 617.324036, 617.330872, 617.337769, 617.344666, 617.351501 nm
(HMI's ±34.4 / ±103.2 / ±172 mÅ tuning points),
landeG = 2.5,lambda0 = 617.33352 nm, 2000 training epochs. - Because a network is only valid for the sampling it was trained on,
initial_guess_from_spectrumchecks the inversion configuration (number of samples,lambda0,landeG, wavelength grid; tolerance1e-4nm) and raises aValueErrorwith the correctMEInversion(...)call if they disagree. Passstrict=Falseto downgrade this to a warning. - The input spectrum must be continuum normalised (
I/Ic, Q/Ic, U/Ic, V/Ic). - The network output is a starting point for the inversion, not a final
answer — refine it with
lm(or an annealing/CMA-ES pass) as in section 5.2. ⚠️ Wavelength convention to be aware of:lambda0 = 6173.3352 Åis the Fe I rest wavelength while the six filter points are centred on the observed disk-centre line position 6173.3433 Å (a difference of 8.1 mÅ ≈ 393 m/s). The network'sv_losis therefore measured relative to 6173.3352 Å. Check that this matches the convention of your HMI reduction before interpreting absolute velocities.
Registering your own network is one line:
from CuSP import register_initial_guess
register_initial_guess('my_line', '/path/to/pi2nn_mine.pkl', 'PI2NN for Fe I 6302.5')
params = inv(iquv_obs, initial_guess='my_line')The Voigt φ(u,a) and Faraday–Voigt ψ(u,a) profiles are the expensive inner
loop of the ME equations. voigt.py evaluates them through a 7/7 complex
rational approximation of the Faddeeva function, with no numerical integration:
from CuSP.voigt import VoigtProfile, VoigtFaradayProfile, voigt_profiles
phi = VoigtProfile(u, a) # (B, Nw) — u (B, Nw), a (B, 1)
psi = VoigtFaradayProfile(u, a) # (B, Nw)
phi, psi = voigt_profiles(u, a) # both at once
from CuSP.voigt import VoigtProfileQuadrature # original trapezoidal version
phi_slow = VoigtProfileQuadrature(u, a, ynodes=1000, lim=10.0)Conventions (unchanged from the original code, so nothing downstream moved):
phi(u, a) = Re w(u + i a) / sqrt(pi) with int phi du = 1
psi(u, a) = Im w(u + i a) / sqrt(pi) (Hilbert partner of phi)
Re w is even and Im w is odd in u, and the rational form covers both signs
of u and all a ≥ 0.
The ynodes / lim arguments of the old quadrature are accepted and ignored.
The rational form is evaluated in the working dtype, so a sufficiently large
|u| would eventually overflow a float32 polynomial; CuSP's operating range
stays well inside that limit.
CuSP (top level exports)
from CuSP import (MEForward, MEInversion, PI2NNInitialGuess, get_initial_guess_model,
initial_guess_from_spectrum, list_initial_guess_models,
load_pi2nn_model, register_initial_guess)me_forward.MEForward(wavebands, landeG=2.5, lambda0=630.25, wing=None)
| Method | Returns |
|---|---|
__call__(Dlambda_D, v_los, eta_0, S10, a_damp, Bmag, theta, phi) |
(4, B, N) Stokes I, Q, U, V |
return_IQUV(...) |
tuple (I, Q, U, V), each (B, N) |
return_profile(Dlambda_D, u_los, u_B, a_damp) |
(φ₀, φ_B, φ_R, ψ₀, ψ_B, ψ_R) |
return_eta_rho(eta_0, theta, phi, φ…, ψ…) |
propagation-matrix elements η_{I,Q,U,V}, ρ_{Q,U,V} |
me_inversion.MEInversion(wavebands, landeG=2.5, lambda0=630.25, wing=None)
(inherits MEForward; overrides __call__ as the inversion entry point, so use
inv.synthesize(params) / inv.return_IQUV(...) for forward synthesis)
__call__, make_initial_guess, synthesize, merit_function,
normalizing_parameter, denormalizing_parameter, plot_inversion_results,
plot_annealing_hist, and the results dict ivs_results.
annealing.py — DualAnnealing(func, bounds, x0, maxiter, adam, initial_temp, visit, accept, no_local_search), plus GSA, VisitDistribution, EnergyState,
AdamLocalSearch, BatchAdam, AnnealingResult.
me_inversion.CudaAnnealing — the csa driver.
lm.py — BatchLM(max_iters, init_damping, damping_mode, step_solver, …):
batched Levenberg–Marquardt over a differentiable forward(x), __call__(Y, guess, sig=…) -> x_best.
cmaes.py — BatchCMAES(max_iters, pop_size, init_sigma, patience, …):
batched CMA-ES (separable/full covariance) with the same calling convention.
me_inverters.py — MEObjective (flattened weighted-chi2 objective),
run_lm(..., rf_method='vector'|'loop'), run_cmaes: the adapters that bind both
optimizers to the ME problem (same normalised box and the same sig as
merit_function).
me_rf.py — VectorizedResponseFunction(forward, wavelengths=None, wing=None, zoom_factor=1.0): element-wise response functions via
__call__(x, normalized=True, layout='nw48'|'lm', return_stokes=False) →
(B, N, 4, 8) or the flat (B, 4N, 8) Jacobian; vectorized_response_function
is the one-shot wrapper, and make_vectorized_lmcoef(objective, ...) is the
_LMcoef-compatible hook that run_lm uses by default.
initial_guess.py — load_pi2nn_model, PI2NNInitialGuess
(predict_physical, predict_normalized, check_inversion, describe),
get_initial_guess_model, initial_guess_from_spectrum,
list_initial_guess_models, register_initial_guess.
voigt.py — VoigtProfile, VoigtFaradayProfile, voigt_profiles,
faddeeva_rational, VoigtProfileQuadrature, VoigtFaradayProfileQuadrature,
VOIGT_RATIONAL_A/B.
PI2NN.py — PI2NN (training loop + forward), InversionNet, FCN,
ResidualBlock, ConvResidualBlock, ChannelAttention, SelfAttention.
- Normalisation. Wavebands are in nm and the Stokes vector is normalised at
the
wingwavelength; feed the inversion continuum-normalised data (I/Ic … V/Ic). - Wavelength reference.
MEForwardcomputesu = (λ − lambda0)/Dlambda_D, solambda0must be the line centre you wantv_losmeasured against. The shippedsdo_hmimodel is deliberately checked against this (section 6). - dtype. float32 is the working precision (the annealer casts internally); float64 is supported and gives a more accurate quadrature reference.
PI2NNandnn.Module.PI2NN.trainis the training loop, so.eval()must not be used on aPI2NNinstance (see section 5.4). Loading the shipped checkpoint does not hit this, but your own code might.method='csa'used to crash withUnboundLocalError: E_init; this is fixed (CudaAnnealing._annealing).'gsa'/'annealing'remains the paper's GBA driver, but'cmaes'is the default method.- Merit values.
ivs_results['e']/['e0']are always re-evaluated from the returned parameters (x/x0), because the annealers' internale_bestcan drift out of sync with thex_bestthey return. - LM Jacobian.
run_lmgets its Jacobian fromme_rf.VectorizedResponseFunction(one backward pass per Stokes component, independent of the wavelength sampling) instead of one backward pass per observable;rf_method='loop'restores the old behaviour. Both are the exact derivative ofMEObjective.forward, i.e. of the objective including its clamp margin: a parameter sitting outsideobjective_clampgets a zero Jacobian column, exactly as before. BatchLM's pyPRT-specific soft bounds penalty is identically zero for the single-groupdecomposition=[8]thatrun_lmconfigures. initial_guess.pyloading. The shipped checkpoint was saved from a script that importedPI2NNas a top-level module, sotorch.loadalone cannot unpickle it;load_pi2nn_modelremaps those class references toCuSP.PI2NN.torch>=2.6also requiresweights_only=False, which the loader sets when supported.
If you use CuSP in a publication, please cite the accompanying paper:
G. Y. Chen, Y. Guo, C. J. Díaz Baso, Q. Hao & M. D. Ding, A synergistic spectropolarimetric inversion via gradient-bias annealing and physics-informed neural networks, Astronomy & Astrophysics 710, A227 (2026). DOI: 10.1051/0004-6361/202659270 (open access, CC BY 4.0)
@ARTICLE{Chen2026synergistic,
author = {{Chen}, G.~Y. and {Guo}, Y. and {D{\'i}az Baso}, C.~J. and
{Hao}, Q. and {Ding}, M.~D.},
title = "{A synergistic spectropolarimetric inversion via gradient-bias
annealing and physics-informed neural networks}",
journal = {A\&A},
year = {2026},
volume = {710},
pages = {A227},
doi = {10.1051/0004-6361/202659270}
}Author: Chen Guoyin · gychen@smail.nju.edu.cn · version 0.1.0