Encoders¶
Experimental surface
torchsky.encoders is experimental. Feature layouts, constructor
arguments, and the contents of saved buffers may change between minor
releases without a deprecation period, which makes old checkpoints
unloadable. There are no pretrained weights and no claim of accuracy on
real catalogues.
An encoder turns astronomical inputs into fixed-length feature vectors
that a neural network can use. Neural networks learn fine structure poorly
from raw coordinates or from catalogue rows with missing values; an encoder
supplies multi-scale features of sky position and wavelength, or a single
latent vector summarizing a variable-length set of photometric measurements.
All encoders are torch.nn.Modules or tensor functions: they run on the input's
device and pass gradients.
| Encoder | Input | Output |
|---|---|---|
harmonic_sky_encoding |
RA, Dec | 9 low-order polynomials on the unit sphere |
log_spectral_encoding |
wavelength or frequency | sines and cosines of \(\ln\lambda\) at octave scales |
SkyCoordinateEncoder |
RA, Dec, optional λ | both of the above plus spherical Fourier features |
PhotometryEncoder |
a set of flux measurements per object | one latent vector per object and a support flag |
MaskedSetFusion |
a set of latent vectors (bands, surveys, modalities) | one fused latent |
BinnedRedshiftHead |
a latent vector | a binned redshift probability distribution |
Use sky encoders when you build your own continuous sky model; most users meet them inside fields. Use the photometry encoder and redshift head for catalogue-level learning such as photometric redshifts, where each object has a different set of bands, surveys, and upper limits.
How It Works¶
Sky and wavelength features. Positions become unit vectors
\((x, y, z)\) on the sphere, which have no wrap at RA = 360° and no
singularity at the poles. Fourier features
\(\sin(2^k \omega_0 \cdot), \cos(2^k \omega_0 \cdot)\) at octave scales
\(k = 0, \dots, K-1\) let a small network represent structure at many angular
scales. The lowest frequency \(\omega_0\) (min_frequency) should match the
field: about \(2\pi/\theta\) for a patch of angular size \(\theta\), so that the
octaves cover structure inside the patch rather than whole-sky modes.
Measurement sets. An object's photometry is a set of tokens, one per measurement: a flux, its error, and integer identifiers of passband, survey, measurement method, and detection status. Each token is embedded by a small network and the set is pooled with learned attention over the valid tokens, so the result does not depend on token order or on how many measurements an object has.
flowchart LR
T["tokens: flux, error,<br/>passband, survey, status, epoch"] --> E["per-token MLP<br/>asinh(flux/scale)"]
E --> P["masked attention pooling"]
P --> L["latent + supported"]
L --> H["BinnedRedshiftHead<br/>p(z) over bins"]
Assumptions¶
- Encoders compute features only. Matching sources between catalogues, choosing training labels and splits, and recording provenance are the application's responsibility.
- Integer identifiers (passband, survey, method) index the caller's vocabularies. They are not global, so save the vocabulary with the model.
- Flux scales and epoch normalization are model buffers saved in
state_dict. Fit any data-dependent scale on training data only.
Sky Features for a Small Field¶
import math
import torch
from torchsky.encoders import SkyCoordinateEncoder
ra = 150.0 + 0.01 * torch.rand(1000, dtype=torch.float64) # 36 arcsec patch
dec = 2.0 + 0.01 * torch.rand(1000, dtype=torch.float64)
wave = torch.full((1000,), 6000.0, dtype=torch.float64)
encoder = SkyCoordinateEncoder(num_fourier_frequencies=6, min_frequency=2 * math.pi / math.radians(0.01))
features = encoder(ra, dec, wavelength=wave)
network = torch.nn.Sequential(torch.nn.Linear(features.shape[-1], 32), torch.nn.SiLU(), torch.nn.Linear(32, 1)).double()
assert network(features).shape == (1000, 1)
Recipes¶
Encode Photometry and Predict a Redshift Distribution¶
import torch
from torchsky.encoders import BinnedRedshiftHead, PhotometryEncoder
encoder = PhotometryEncoder(num_passbands=3, num_surveys=2, flux_scale=[10.0, 10.0, 20.0], latent_dim=32)
flux = torch.tensor([[8.0, -2.0, 30.0], [float("nan"), 6.0, 0.0]])
error = torch.tensor([[1.0, 2.0, float("nan")], [float("nan"), 1.0, 0.0]])
mask = torch.tensor([[True, True, True], [False, True, False]]) # False = padding
latent, supported = encoder(
flux, error,
passband_id=torch.tensor([[0, 1, 2], [-1, 1, -1]]),
survey_id=torch.tensor([[0, 0, 1], [-1, 0, -1]]),
status=torch.tensor([[0, 0, 1], [-1, 0, -1]]), # 1 = upper limit
epoch=torch.tensor([[2016.0, 2016.0, float("nan")], [0.0, 2016.0, 0.0]]),
mask=mask,
)
head = BinnedRedshiftHead(32, [0.0, 0.2, 0.5, 1.0, 2.0, 4.0])
logits = head(latent, supported=supported)
loss = -head.log_prob(logits, torch.tensor([0.4, 1.2])).mean()
loss.backward()
| Input | Meaning |
|---|---|
flux |
Signed flux, or the threshold of an upper limit; finite where observed |
error |
Positive uncertainty in the same unit; NaN means unknown |
passband_id, survey_id, method_id |
Indices into the caller's vocabularies |
status |
0 for a detection, 1 for an upper limit |
epoch |
Decimal year of the observation; NaN means unknown |
mask |
False for padding, not for a non-detection |
Continuous features are \(\operatorname{asinh}(f/s)\) and
\(\ln(1 + \sigma/s)\) with one scale \(s\) per passband (flux_scale), so
negative and very bright fluxes stay finite. Unknown errors and epochs carry
their own availability flag; a placeholder zero never means zero noise.
log_prob evaluates a piecewise-uniform density: bin probability divided by
bin width. Bins are left-closed, the last also right-closed; a target outside
the bins has log probability \(-\infty\).
Fuse Other Modalities¶
MaskedSetFusion
pools any set of latent vectors of shape (..., items, latent_dim) with a
mask, for example image, spectrum, and photometry latents mapped to a common
size. Fully masked sets give a zero latent with supported=False; pass that
flag on, because the head rejects unsupported rows.
Average Over Cross-Match Hypotheses¶
When a source has several possible counterparts,
marginalize_association_logits
mixes the redshift distributions of each hypothesis with their weights. This
averages probabilities, not embeddings, which would give a different answer.
import torch
from torchsky.encoders import marginalize_association_logits
conditional_logits = torch.tensor([[[2.0, 0.0], [0.0, 2.0]]]) # 2 hypotheses x 2 bins
weights = torch.tensor([[0.75, 0.25]])
log_mass = marginalize_association_logits(conditional_logits, weights)
torch.testing.assert_close(log_mass.exp().sum(-1), torch.ones(1))
Include "no match" and blend hypotheses with their own models when they are possible; otherwise the result is conditional on the hypotheses supplied.
A Catalogue Photometric-Redshift Application¶
examples/applications/catalogue_photoz.py
trains, predicts, and evaluates a photo-z model from a directory of photometry
shards, a membership table (source_id, object_id, split, split_group),
and spectroscopic labels (object_id, redshift, evidence_id, category).
It streams a Parquet feature cache through Polars and Arrow, keeps lineage
groups within one split, pins every input by hash in the checkpoint, and reports
NLL, outlier fraction, PIT histograms, and interval coverage on the test split.
It needs the test environment and your own data:
pixi run -e test -- python examples/applications/catalogue_photoz.py train \
--release /data/observations-dr1 --memberships memberships.parquet \
--labels spec-labels.parquet --feature-cache photoz-features \
--checkpoint photoz.pt --epochs 20 --bin-edges 0,0.2,0.5,1,2,4,6
The bundled synthetic check shows the pipeline learns and reloads correctly; it does not establish photo-z accuracy on a real survey.
Photometric Redshifts of Real SDSS Galaxies¶
The SDSS photo-z example trains
PhotometryEncoder and BinnedRedshiftHead from scratch on the ugriz fluxes
of 5001 SDSS DR17 spectroscopic galaxies and tests on 2144 others. In 300
epochs (about 35 s on a CPU) it reaches σ_NMAD = 0.027 in Δz/(1+z) with 0.9%
outliers (|Δz|/(1+z) > 0.15); the SDSS DR17 Photoz table, trained on far more
spectra, scores 0.018 and 0.6% on the same galaxies. The metrics hold for the
spectroscopic selection only, not for a photometric sample. For a fixed
five-band catalogue, every token still needs explicit passband_id and
survey_id tensors of the flux shape:
# fragment: flux, error are (N, 5) tensors in nanomaggies; NaN error = unknown
passband = torch.arange(5).expand(len(flux), -1)
latent, supported = encoder(flux, error, passband, torch.zeros_like(passband))
loss = -head.log_prob(head(latent, supported=supported), z_spec).mean()

Image Transforms for Training¶
torchsky.transforms holds tensor transforms for image training pipelines
(ZScale, AsinhStretch, RandomCrop, RandomFlip, PoissonNoise,
PerturbByError, Compose, and others). Load files with torchfits in your
Dataset, then transform:
import torch
from torchsky.transforms import Compose, MinMaxScale, ZScale
image = torch.randn(64, 64) * 3.0 + 100.0
scaled = Compose([ZScale(), MinMaxScale()])(image)
assert scaled.shape == image.shape and 0.0 <= float(scaled.min()) <= float(scaled.max()) <= 1.0
Wavelength-aware operations (rebinning, Doppler shifts) are spectral operations, not augmentations; see Spectral axes.
Pitfalls¶
- Coordinates in float32. Encode absolute RA/Dec in float64, or subtract
the field centre first, as fields do through their
Domain. - Equinox is not epoch.
epochis when a flux was measured, not the coordinate equinox of the catalogue. - Vocabulary drift. Adding a band or survey changes the meaning of integer identifiers; retrain or migrate the checkpoint explicitly.
- Unsupported rows. An object with no usable measurement gets a zero latent; that is not a prior, and its prediction must be skipped.
See Also¶
- Fields: continuous sky models built on these features.
- Synthetic photometry: model fluxes for training sets.
- Formulation: why multi-scale features matter for INRs.
- Reference:
torchsky.encoders,torchsky.transforms.