|
| 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