Skip to content

Commit 19435ef

Browse files
authored
Merge pull request #692 from light-curve/embed
New sub-package for ML-based light-curve embeddings
2 parents 0adec0b + e4a2b5c commit 19435ef

8 files changed

Lines changed: 1256 additions & 1 deletion

File tree

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
[submodule "test-data"]
22
path = light-curve/tests/light-curve-test-data
33
url = https://github.com/light-curve/test-data.git
4+
[submodule "light-curve/tests/prep-models"]
5+
path = light-curve/tests/prep-models
6+
url = https://github.com/light-curve/prep-models.git

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
--
12+
- New `light_curve.embed` module with ONNX-backed light curve embedding models ([#692](https://github.com/light-curve/light-curve-python/pull/692)):
13+
- `Astromer1` and `Astromer2` — transformer encoders pretrained on MACHO light curves (Donoso-Oliva et al. 2023/2026), returning 256-dimensional embeddings. Load directly from HuggingFace with `Astromer2.from_hf()`. Use the `output` parameter to select which named output to compute (`"mean"` (default), `"max"`, or `"sequence"`); onnxruntime prunes unused computation automatically.
14+
- `NonOverlappingWindows`, `Beginning`, `End`, `RandomSubsample`, `MultipleReductions` — strategies for mapping variable-length light curves to fixed-length model inputs.
15+
- `InputTensors` / `AstromerInputs` — typed dataclass containers for preprocessed tensors.
16+
- `Dim` — enum of axis indices for the 4-D output array `(BAND, SUBSAMPLE, SEQUENCE, VALUE)`.
1317

1418
### Changed
1519

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from .astromer import Astromer1, Astromer2, create_onnx_session
2+
from .model import AstromerInputs, Dim, EmbeddingSession, SingleBandModel
3+
from .reduction import (
4+
Beginning,
5+
End,
6+
InputTensors,
7+
MultipleReductions,
8+
NonOverlappingWindows,
9+
RandomSubsample,
10+
Reduction,
11+
SingleSubsampleReduction,
12+
reduction_from_str,
13+
)
14+
15+
__all__ = [
16+
"Astromer1",
17+
"Astromer2",
18+
"AstromerInputs",
19+
"Beginning",
20+
"Dim",
21+
"EmbeddingSession",
22+
"End",
23+
"InputTensors",
24+
"MultipleReductions",
25+
"NonOverlappingWindows",
26+
"RandomSubsample",
27+
"Reduction",
28+
"SingleBandModel",
29+
"SingleSubsampleReduction",
30+
"create_onnx_session",
31+
"reduction_from_str",
32+
]
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
from __future__ import annotations
2+
3+
from typing import Sequence
4+
5+
import numpy as np
6+
from numpy.typing import ArrayLike
7+
8+
from .model import AstromerInputs, SingleBandModel
9+
from .reduction import Reduction
10+
11+
_ONNX_INSTALL_HINT = (
12+
"An ONNX runtime is required to run embedding models. "
13+
"Install the variant that matches your hardware:\n"
14+
" CPU / Apple Silicon: pip install onnxruntime\n"
15+
" NVIDIA GPU (CUDA): pip install onnxruntime-gpu\n"
16+
" Windows DirectML: pip install onnxruntime-directml\n"
17+
"See https://onnxruntime.ai for the full list of packages."
18+
)
19+
20+
21+
def create_onnx_session(model_path: str, **kwargs):
22+
"""Create an ``onnxruntime.InferenceSession``, with a helpful error if the package is missing.
23+
24+
Parameters
25+
----------
26+
model_path : str
27+
Path to the ONNX model file.
28+
**kwargs
29+
Forwarded verbatim to ``onnxruntime.InferenceSession``.
30+
31+
Returns
32+
-------
33+
onnxruntime.InferenceSession
34+
A ready-to-use inference session.
35+
36+
Raises
37+
------
38+
ImportError
39+
If no onnxruntime variant is installed, with installation instructions.
40+
"""
41+
try:
42+
import onnxruntime as ort
43+
except ImportError as exc:
44+
raise ImportError(_ONNX_INSTALL_HINT) from exc
45+
return ort.InferenceSession(model_path, **kwargs)
46+
47+
48+
class _AstromerModel(SingleBandModel):
49+
"""Internal base class for Astromer-family embedding models.
50+
51+
Provides shared preprocessing (per-window zero-mean normalisation) and
52+
ONNX inference logic for all Astromer variants. Concrete subclasses set
53+
:attr:`_HF_REPO` and, if needed, override the default ``reduction``.
54+
55+
Output shape
56+
------------
57+
:meth:`__call__` always returns a 4-D array
58+
``(n_bands, n_subsamples, seq_size, embed_dim)`` for consistency across
59+
models and windowing strategies:
60+
61+
* ``n_bands`` — number of photometric bands; 1 when ``bands`` is ``None``.
62+
* ``n_subsamples`` — windows produced by the time reduction (1 for
63+
:class:`NonOverlappingWindows`, which averages all windows).
64+
* ``seq_size`` — 1 for the ``"mean"`` and ``"max"`` outputs (aggregated
65+
over the sequence); equal to the model's sequence length for ``"sequence"``.
66+
* ``embed_dim`` — embedding dimension (256 for all Astromer models).
67+
68+
Use ``embedding.squeeze()`` to collapse unit dimensions when the full
69+
4-D layout is not needed.
70+
"""
71+
72+
seq_size: int = 200
73+
dtype: type = np.float32
74+
_HF_REPO: str
75+
_OUTPUTS: frozenset[str] = frozenset({"mean", "max", "sequence"})
76+
77+
def __init__(
78+
self,
79+
session,
80+
*,
81+
output: str = "mean",
82+
bands: Sequence[str | int] | None = None,
83+
reduction: str | list[str] | Reduction = "non-overlapping-windows",
84+
time_red_kwargs: dict[str, object] | None = None,
85+
) -> None:
86+
super().__init__(
87+
session,
88+
bands=bands,
89+
reduction=reduction,
90+
time_red_kwargs=time_red_kwargs,
91+
)
92+
if output not in self._OUTPUTS:
93+
raise ValueError(f"Unknown output '{output}'. Must be one of: {', '.join(sorted(self._OUTPUTS))}")
94+
self.output = output
95+
96+
@classmethod
97+
def from_hf(
98+
cls,
99+
output: str = "mean",
100+
*,
101+
bands: Sequence[str | int] | None = None,
102+
reduction: str | list[str] | Reduction = "non-overlapping-windows",
103+
time_red_kwargs: dict[str, object] | None = None,
104+
providers=None,
105+
sess_options=None,
106+
) -> "_AstromerModel":
107+
"""Load a model from the HuggingFace Hub.
108+
109+
Downloads (and caches) the ONNX model file, creates an
110+
``onnxruntime.InferenceSession``, and returns a ready-to-use instance.
111+
Only the requested output is computed at inference time — onnxruntime
112+
prunes the unused computation graph automatically.
113+
114+
Parameters
115+
----------
116+
output : str, optional
117+
Named ONNX output to return. One of:
118+
119+
* ``"mean"`` (default) — masked mean pooling over valid timesteps,
120+
output shape ``(batch, 256)``
121+
* ``"max"`` — masked max pooling over valid timesteps,
122+
output shape ``(batch, 256)``
123+
* ``"sequence"`` — per-timestep embeddings (no aggregation),
124+
output shape ``(batch, seq_size, 256)``
125+
126+
bands : sequence of str or int, optional
127+
Ordered band labels to embed. ``None`` (default) treats the whole
128+
light curve as one band.
129+
reduction : str, list of str, or Reduction, optional
130+
Windowing / subsampling strategy. Defaults to
131+
``"non-overlapping-windows"``.
132+
time_red_kwargs : dict, optional
133+
Extra keyword arguments forwarded to :func:`reduction_from_str`
134+
when ``reduction`` is given as a string.
135+
providers : list of str, optional
136+
ONNX Runtime execution providers, e.g.
137+
``["CUDAExecutionProvider", "CPUExecutionProvider"]``.
138+
sess_options : onnxruntime.SessionOptions, optional
139+
Advanced session configuration passed directly to
140+
``onnxruntime.InferenceSession``.
141+
142+
Returns
143+
-------
144+
instance of the calling class
145+
Instance with a live ONNX inference session.
146+
147+
Raises
148+
------
149+
ValueError
150+
If ``output`` is not one of the recognised output names.
151+
ImportError
152+
If ``huggingface_hub`` is not installed, with instructions to
153+
install it or to download the model file manually.
154+
ImportError
155+
If no ``onnxruntime`` variant is installed, with hardware-specific
156+
installation instructions.
157+
"""
158+
if output not in cls._OUTPUTS:
159+
raise ValueError(f"Unknown output '{output}'. Must be one of: {', '.join(sorted(cls._OUTPUTS))}")
160+
161+
model_prefix = cls._HF_REPO.split("/")[-1]
162+
filename = f"{model_prefix}.onnx"
163+
164+
try:
165+
from huggingface_hub import hf_hub_download
166+
except ImportError as exc:
167+
hf_url = f"https://huggingface.co/{cls._HF_REPO}/resolve/main/{filename}"
168+
raise ImportError(
169+
"huggingface_hub is required to download models from HuggingFace.\n"
170+
"Install it with:\n"
171+
" pip install huggingface-hub\n"
172+
"Or download the model file directly:\n"
173+
f" {hf_url}\n"
174+
"then load it with:\n"
175+
" import onnxruntime as ort\n"
176+
f' {cls.__name__}(session=ort.InferenceSession("/path/to/{filename}"), output="{output}")'
177+
) from exc
178+
179+
model_path = hf_hub_download(repo_id=cls._HF_REPO, filename=filename)
180+
181+
session_kwargs = {}
182+
if providers is not None:
183+
session_kwargs["providers"] = providers
184+
if sess_options is not None:
185+
session_kwargs["sess_options"] = sess_options
186+
187+
session = create_onnx_session(model_path, **session_kwargs)
188+
return cls(
189+
session=session,
190+
output=output,
191+
bands=bands,
192+
reduction=reduction,
193+
time_red_kwargs=time_red_kwargs,
194+
)
195+
196+
def preprocess_lc(
197+
self,
198+
time: ArrayLike,
199+
mag: ArrayLike,
200+
) -> AstromerInputs:
201+
"""Preprocess a light curve into Astromer model input tensors.
202+
203+
Each window is independently zero-mean normalised (time and magnitude)
204+
using only the valid (non-padded) observations. Normalisation is
205+
computed in the original input precision before casting to ``float32``
206+
to avoid precision loss for large values such as MJD timestamps.
207+
208+
Parameters
209+
----------
210+
time : array-like, shape ``(n,)``
211+
Observation times (e.g. MJD).
212+
mag : array-like, shape ``(n,)``
213+
Magnitudes.
214+
215+
Returns
216+
-------
217+
AstromerInputs
218+
``times`` and ``input`` are per-window zero-mean arrays of shape
219+
``(n_windows, seq_size, 1)`` in float32; ``mask`` is 1 for valid
220+
observations and 0 for zero-padded positions, same shape and dtype.
221+
"""
222+
time, mag, mask = self.reduction.preprocess_lc(time, mag, seq_size=self.seq_size)
223+
224+
bool_mask = mask # (n_windows, seq_size), boolean
225+
n_valid = mask.sum(axis=-1, keepdims=True)
226+
time_mean = (time * mask).sum(axis=-1, keepdims=True) / n_valid
227+
mag_mean = (mag * mask).sum(axis=-1, keepdims=True) / n_valid
228+
time = np.where(mask, time - time_mean, time).astype(self.dtype)
229+
mag = np.where(mask, mag - mag_mean, mag).astype(self.dtype)
230+
mask = mask.astype(self.dtype)
231+
232+
idx = (..., np.newaxis)
233+
return AstromerInputs(times=time[idx], input=mag[idx], mask=mask[idx], bool_mask=bool_mask)
234+
235+
def predict_tensors(self, tensors: AstromerInputs) -> np.ndarray:
236+
"""Run the ONNX model on pre-processed tensors and return reduced embeddings.
237+
238+
Parameters
239+
----------
240+
tensors : AstromerInputs
241+
As returned by :meth:`preprocess_lc`.
242+
243+
Returns
244+
-------
245+
np.ndarray, shape ``(n_subsamples, seq_size, embed_dim)``
246+
Embeddings after applying the time reduction's aggregation.
247+
For aggregated models (mean / max) ``seq_size`` is 1.
248+
"""
249+
(raw_embedding,) = self.session.run(
250+
[self.output],
251+
{"input": tensors.input, "times": tensors.times, "mask_in": tensors.mask},
252+
)
253+
254+
# Aggregated outputs (mean / max) have shape (n_windows, embed_dim); add SEQUENCE=1 axis
255+
if raw_embedding.ndim == 2:
256+
raw_embedding = np.expand_dims(raw_embedding, axis=1)
257+
258+
return self.reduction.reduce_embeddings(raw_embedding, tensors, output=self.output)
259+
260+
261+
class Astromer1(_AstromerModel):
262+
"""Astromer 1 embedding model (Donoso-Oliva et al. 2023).
263+
264+
Transformer encoder pretrained on MACHO R-band light curves via masked
265+
magnitude prediction. Accepts irregularly-sampled single-band photometry
266+
and returns a 256-dimensional embedding (2 layers, 4 attention heads).
267+
268+
The ONNX model is hosted on HuggingFace at
269+
``https://huggingface.co/light-curve/astromer1`` (``astromer1.onnx``).
270+
Three named outputs are available; select with the ``output`` parameter:
271+
272+
* ``"mean"`` (default) — masked mean pooling → shape ``(batch, 256)``
273+
* ``"max"`` — masked max pooling → shape ``(batch, 256)``
274+
* ``"sequence"`` — per-timestep features → shape ``(batch, 200, 256)``
275+
276+
Use :meth:`from_hf` to download and load the model directly.
277+
278+
Parameters
279+
----------
280+
session :
281+
ONNX inference session for the Astromer 1 model file.
282+
output : str, optional
283+
Which named output to return: ``"mean"``, ``"max"``, or ``"sequence"``.
284+
Defaults to ``"mean"``.
285+
bands : sequence of str or int, optional
286+
Band labels. ``None`` (default) treats the whole light curve as one
287+
band.
288+
reduction : str, list of str, or Reduction
289+
Windowing strategy. Defaults to :class:`NonOverlappingWindows`.
290+
"""
291+
292+
_HF_REPO = "light-curve/astromer1"
293+
294+
295+
class Astromer2(_AstromerModel):
296+
"""Astromer 2 embedding model (Donoso-Oliva et al. 2026).
297+
298+
Pretrained on 1.5 million MACHO light curves. Accepts irregularly-sampled
299+
single-band photometry and returns a 256-dimensional embedding.
300+
301+
The ONNX model is hosted on HuggingFace at
302+
``https://huggingface.co/light-curve/astromer2`` (``astromer2.onnx``).
303+
Three named outputs are available; select with the ``output`` parameter:
304+
305+
* ``"mean"`` (default) — masked mean pooling → shape ``(batch, 256)``
306+
* ``"max"`` — masked max pooling → shape ``(batch, 256)``
307+
* ``"sequence"`` — per-timestep features → shape ``(batch, 200, 256)``
308+
309+
Use :meth:`from_hf` to download and load the model directly.
310+
311+
Parameters
312+
----------
313+
session :
314+
ONNX inference session for the Astromer 2 model file.
315+
output : str, optional
316+
Which named output to return: ``"mean"``, ``"max"``, or ``"sequence"``.
317+
Defaults to ``"mean"``.
318+
bands : sequence of str or int, optional
319+
Band labels. ``None`` (default) treats the whole light curve as one
320+
band.
321+
reduction : str, list of str, or Reduction
322+
Windowing strategy. Defaults to :class:`NonOverlappingWindows`, which
323+
matches the sequential-window preprocessing used to produce the reference
324+
embeddings on HuggingFace.
325+
"""
326+
327+
_HF_REPO = "light-curve/astromer2"

0 commit comments

Comments
 (0)