noaa-goes-visualization/suvi/samples.py

404 lines
20 KiB
Python
Raw Permalink Normal View History

"""Turning shards into training samples: stacks, masks, and damage applied on the fly.
One sample is a target slot -- one satellite, one instant, all six bands -- plus the
stack of frames the model may draw on and a per-frame conditioning vector. This module
is the only place that knows how a stack is laid out, so :mod:`suvi.model` and
:mod:`bench` cannot disagree about it.
Three things here decide what the model can learn.
**Offsets are exponentially spaced.** Gaps in this archive run from one slot to three
hundred, and a fixed +/-K window either misses the far end of a long outage or wastes
most of its inputs on a short one. Sampling at ``+/-{1, 4, 16, 64, 256}`` slots covers
four minutes to seventeen hours with ten frames per satellite, and an *anchor* -- the
nearest usable frame in each direction, however far -- guarantees the stack is never
empty even beyond that.
**Damage is applied here, not baked into the shards.** Every epoch draws fresh
corruptions from :mod:`suvi.corruptions`, so the model sees far more variety than a
frozen dataset could hold, and the true mode is known exactly for the auxiliary head.
**Outages are episodic as well as scattered.** Half of all samples carry a contiguous
simulated outage -- up to 400 slots, on one satellite or both -- because that is how
the real archive fails: measured over the two-satellite era, 11% of slots have both
spacecraft dark at once, in long runs. Independent per-frame drops alone would never
construct that case, and a model cannot learn a regime it has never seen.
**Context frames get damaged too, not just the target.** This is the requirement that
any supplied frame may itself be invalid. A model trained on clean context and deployed
on an archive where 31% of slots are bad on both satellites would meet, at inference, a
distribution it had never seen.
"""
import math
import numpy as np
from . import align, corruptions, dataset, paths
#: Slot offsets sampled on each satellite, in both directions. Exponentially spaced,
#: so one fixed-size stack serves every gap length: 4 minutes to 17 hours in five
#: steps, without needing hundreds of input frames to cover a 300-slot outage. The
#: anchors (nearest usable frame either way, at any distance) still guarantee the
#: stack is never empty beyond the ladder's reach. Cost is linear in stack depth in
#: the current model (no attention), so the two extra rungs are affordable where they
#: were not for the attention-based design this replaced.
OFFSETS = (1, 4, 16, 64, 256)
#: How often a sample carries a simulated *episodic* outage -- a contiguous run of
#: missing slots -- on top of the scattered per-frame drops. Real outages are runs,
#: not confetti: measured against the archive, 11% of the two-satellite era has BOTH
#: spacecraft dark simultaneously, in 1,096 contiguous runs of up to 26 days. With
#: independent per-frame drops the probability of reproducing that configuration in
#: a training stack is roughly p^14 -- the model would face at evaluation a regime
#: it had never once seen.
EPISODIC_PROBABILITY = 0.5
#: Given an episodic outage, how often it takes down *both* satellites at once (a
#: ground-segment or space-weather event) rather than one.
DUAL_OUTAGE_PROBABILITY = 0.4
#: Longest simulated outage, in slots; matches the anchor search limit, so a stack
#: always retains something real beyond the outage's edge.
MAX_OUTAGE_SLOTS = 400
#: Modes that leave a frame with no usable signal at all. A frame damaged this way is
#: presented as `missing`; everything else is presented as `suspect` with its pixels.
NO_SIGNAL = frozenset({"all_zero", "drop_image_hdu", "nan_fill", "zblank_fill"})
#: Auxiliary-head classes: clean plus every catalogued mode, in a fixed order so a
#: checkpoint's class indices stay meaningful.
CLASSES = ("clean",) + tuple(sorted(corruptions.CATALOG))
CLASS_INDEX = {name: index for index, name in enumerate(CLASSES)}
def stack_layout(satellites, target_satellite):
"""The (satellite, offset) slots a stack is built from, in a fixed order.
Deterministic and independent of what happens to be available, so a frame's position
in the stack always means the same thing and the model can rely on it.
The target satellite's own frame at offset 0 is the frame being reconstructed and is
never an input. The *counterpart's* frame at offset 0 is the single most valuable
entry in the stack -- a real observation of the right Sun at the right instant, worth
~46 dB on its own once its instrument gain is known.
"""
others = [s for s in satellites if s != target_satellite]
layout = []
for satellite in [target_satellite] + others:
for offset in OFFSETS:
layout.append((satellite, -offset))
layout.append((satellite, offset))
return layout + [(other, 0) for other in others]
class Sampler:
"""Builds training samples from a set of shards.
`shards` maps (day, satellite) -> :class:`suvi.dataset.Shard`.
"""
def __init__(self, shards, satellites=(16, 18), damage_probability=0.30,
drop_probability=0.40, seed=0):
self.shards = shards
self.satellites = tuple(satellites)
#: Ceilings, not rates. Each *sample* draws its own severity uniformly up to
#: these, rather than every frame being damaged at one fixed probability.
#:
#: Fixed rates of 0.25 drop / 0.35 damage left barely half the stack clean in
#: every single sample, so the model never saw the easy case and learned a
#: hedging average instead of "trust the counterpart". It showed up as a model
#: that gained 5 dB on its own harsh validation set and ~1 dB on the bench,
#: where neighbours are mostly clean. Real outages are episodic -- long clean
#: stretches broken by bad runs -- so drawing severity per sample covers both
#: regimes, and roughly a fifth of samples come through almost untouched.
self.damage_probability = damage_probability
self.drop_probability = drop_probability
self.rng = np.random.default_rng(seed)
self._by_satellite = {}
self._day_of = {}
for (day, satellite), shard in shards.items():
self._day_of[id(shard)] = day
self._by_satellite.setdefault(satellite, {}).update(
{time: shard for time in shard.times()}
)
#: (day, target satellite, other satellite, pair time) -> per-band (gains,
#: offsets). See :meth:`_calibration`.
self._transfers = {}
def targets(self):
"""Every (satellite, time) that could be reconstructed, in a stable order."""
found = []
for satellite in self.satellites:
for time in sorted(self._by_satellite.get(satellite, {})):
found.append((satellite, time))
return found
def _read(self, satellite, time):
"""One slot in *model* space -- the cheap path, used for every clean frame.
Shards store the asinh representation already, so this is a rescale rather than
the sinh/arcsinh round trip that going via radiance would cost.
"""
shard = self._by_satellite.get(satellite, {}).get(time)
if shard is None:
return None
return shard.coded(time) if hasattr(shard, "coded") else encode_for_model(
shard.frames(time)
)
def _donor(self, satellite, time, rng, reach=60):
"""Another real frame, for the modes that splice one in.
``frozen`` repeats a neighbour, ``wrong_time`` files one under the wrong stamp
and ``torn_frame`` merges two. All three need a *plausible* second observation,
so the donor is drawn from nearby on the same satellite rather than invented.
"""
available = self._by_satellite.get(satellite, {})
for _ in range(8):
offset = int(rng.integers(-reach, reach + 1))
if offset == 0:
continue
candidate = time + offset * paths.CADENCE
if candidate in available:
return self._read(satellite, candidate)
return None
def _anchor(self, satellite, time, direction, exclude=frozenset(), limit=400):
"""Nearest slot with a frame outside `exclude`, up to `limit` slots away.
`exclude` holds the times inside a simulated outage. The production stack
builder picks anchors from *clean* times, so the sampler must too: an anchor
that lands inside the outage and arrives `missing` would leave the stack with
no real long-range frame at all, which is not what the pipeline would see.
"""
available = self._by_satellite.get(satellite, {})
for step in range(1, limit + 1):
candidate = time + direction * step * paths.CADENCE
if candidate in available and candidate not in exclude:
return candidate
return None
def _outages(self, rng, time):
"""Per-satellite times inside this sample's simulated episodic outage.
One contiguous run per affected satellite, log-uniform in length from one
slot to :data:`MAX_OUTAGE_SLOTS`, positioned so the target usually sits
inside it -- reproducing the archive's real failure mode of equipment and
comms dropping out for hours to days, sometimes on both spacecraft at once.
"""
blocked = {satellite: frozenset() for satellite in self.satellites}
# Gated on the drop setting: drop_probability=0 means "no synthetic losses
# of any kind", which diagnostics and tests rely on.
if self.drop_probability <= 0 or rng.random() >= EPISODIC_PROBABILITY:
return blocked
length = int(round(math.exp(rng.uniform(0.0, math.log(MAX_OUTAGE_SLOTS)))))
centre = time + int(rng.integers(-length, length + 1)) * paths.CADENCE
start = centre - (length // 2) * paths.CADENCE
interval = frozenset(start + i * paths.CADENCE for i in range(length))
if rng.random() < DUAL_OUTAGE_PROBABILITY:
hit = self.satellites
else:
hit = (self.satellites[int(rng.integers(len(self.satellites)))],)
for satellite in hit:
blocked[satellite] = interval
return blocked
def _calibration(self, day, satellite, other, exclude):
"""Per-band (gains, offsets) putting `other`'s radiance on `satellite`'s scale.
Fitted from one simultaneous pair per day -- the transfer drifts ~12% per
*week*, so a fit at most 24 hours stale is well inside its own noise, and one
pair per day is what a production pipeline could equally afford.
`exclude` is the time being reconstructed. A pair at that instant would fit
the transfer against the answer itself -- the oracle gain the whole exercise
exists to estimate honestly -- so the fit steps to a neighbouring pair
instead. Returns identity when the day has no usable pair.
"""
local = self.shards.get((day, satellite))
counterpart = self.shards.get((day, other))
if local is None or counterpart is None:
return None
candidates = sorted(set(local.times()) & set(counterpart.times()) - {exclude})
if not candidates:
return None
pair_time = candidates[len(candidates) // 2]
key = (day, satellite, other, pair_time)
if key not in self._transfers:
self._transfers[key] = align.fit_photometry(
counterpart.frames(pair_time), local.frames(pair_time)
)
return self._transfers[key]
def build(self, satellite, time, rng=None):
"""One sample: the stack, its per-frame metadata, and the withheld target.
`frames` is (S, 6, H, W) in *model* space -- roughly [-1, 1] asinh radiance --
with zeros where a frame is missing; `target` is the withheld frame in the same
space. `gains`/`offsets` are the per-frame radiance transfers that put a
cross-satellite frame on the target instrument's scale (identity rows for
same-satellite and missing frames), and `b0`/`radius` the solar geometry the
rotation warp needs -- both consumed by :func:`suvi.align.align_stack` rather
than here, so the CPU loader stays cheap.
Returns None if the target itself is absent, or if not one frame in the stack
carries pixels -- there is nothing to reconstruct *from*, and the model's
contract is to decline rather than emit a fabricated frame.
"""
rng = rng if rng is not None else self.rng
target = self._read(satellite, time)
if target is None:
return None
day = self._day_of.get(id(self._by_satellite[satellite][time]))
# This sample's severity, drawn once and applied to every frame in its stack.
drop_probability = float(rng.uniform(0.0, self.drop_probability))
damage_probability = float(rng.uniform(0.0, self.damage_probability))
outages = self._outages(rng, time)
layout = list(stack_layout(self.satellites, satellite))
# Anchors: the nearest usable frame either way on the target satellite --
# outside any simulated outage, as the production stack builder would pick
# them -- so a long gap still has something real to work from.
for direction in (-1, 1):
anchor = self._anchor(satellite, time, direction, exclude=outages[satellite])
if anchor is not None:
offset = (anchor - time) // paths.CADENCE
if (satellite, offset) not in layout:
layout.append((satellite, offset))
frames, states, dts, same, classes, transfers = [], [], [], [], [], []
for source_satellite, offset in layout:
when = time + offset * paths.CADENCE
image = self._read(source_satellite, when)
state, label = "available", "clean"
if image is None:
state = "missing"
elif when in outages[source_satellite]:
# Inside this sample's simulated episodic outage.
image, state = None, "missing"
elif rng.random() < drop_probability:
# A scattered single-slot loss, independent of the episodic runs.
image, state = None, "missing"
elif rng.random() < damage_probability:
mode = str(rng.choice(sorted(corruptions.CATALOG)))
corruption = corruptions.CATALOG[mode]
label = mode
if corruption.kind != "array":
# A file-level corruption destroys the container, so by the time a
# frame would reach the model there is nothing left of it.
image, state = None, "missing"
elif mode in NO_SIGNAL:
image, state = None, "missing"
else:
donor = self._donor(source_satellite, when, rng) \
if corruption.needs_donor else None
if corruption.needs_donor and donor is None:
label, state = "clean", "available" # nothing to draw from
else:
image = _damage(image, mode, int(rng.integers(0, 2**31 - 1)),
float(rng.uniform(0.5, 1.0)), donor)
state = "suspect"
shape = target.shape
frames.append(np.zeros(shape, dtype=np.float32) if image is None else image)
states.append(state)
dts.append(float(offset * paths.CADENCE))
same.append(source_satellite == satellite)
classes.append(CLASS_INDEX[label])
transfer = None
if source_satellite != satellite and image is not None:
transfer = self._calibration(day, satellite, source_satellite, time)
transfers.append(transfer)
if all(state == "missing" for state in states):
return None
identity = (np.ones(6, dtype=np.float32), np.zeros(6, dtype=np.float32))
b0, radius = align.solar_ephemeris(float(time))
return {
"frames": np.stack(frames),
"states": states,
"dts": np.array(dts, dtype=np.float32),
"same_satellite": np.array(same, dtype=bool),
"classes": np.array(classes, dtype=np.int64),
"gains": np.stack([(t or identity)[0] for t in transfers]),
"offsets": np.stack([(t or identity)[1] for t in transfers]),
"b0": float(b0),
"radius": float(radius),
"target": target,
"slot": (satellite, time),
}
def _damage(coded, mode, seed, severity, donor=None):
"""Apply one array corruption to all six bands of a frame, in model space.
Corruptions are *defined* on radiance -- ``gain_shift`` multiplies a physical
quantity, ``saturate`` clips against a physical ceiling -- so the frame is decoded,
damaged, and re-encoded. This is the expensive path, which is why only the frames
actually being corrupted take it.
Each band gets the same seed, so a fault hits the frame coherently rather than
differently per band, which is how the real ones behave: one instrument produces all
six. ``apply_array`` returns (pixels, header_overrides); the overrides describe a
FITS header this pipeline does not carry, so only the pixels are kept.
"""
radiance = decode_from_model(coded)
donor_radiance = None if donor is None else decode_from_model(donor)
bands = []
for index, band in enumerate(radiance):
damaged, _ = corruptions.apply_array(
mode, band, seed, severity,
donor=None if donor_radiance is None else donor_radiance[index],
)
bands.append(damaged)
return encode_for_model(np.stack(bands))
def encode_for_model(radiance):
"""Radiance -> the roughly-[-1, 1] asinh space the model works in.
Corruptions are applied in *radiance*, because that is where they are defined -- a
``gain_shift`` multiplies a physical quantity and a ``saturate`` clips against a
physical ceiling. Applying them after the transform would model a different fault.
So the encoding happens here, once, on the way into the network.
"""
coded = np.arcsinh(
np.nan_to_num(np.asarray(radiance, dtype=np.float32), nan=0.0,
posinf=0.0, neginf=0.0) / dataset.ASINH_SCALE
) / dataset.ASINH_RANGE
return np.clip(coded, -1.0, 1.0).astype(np.float32)
def decode_from_model(coded):
"""Inverse of :func:`encode_for_model`, back to radiance."""
scaled = np.asarray(coded, dtype=np.float64) * dataset.ASINH_RANGE
return (np.sinh(np.clip(scaled, -dataset.ASINH_RANGE, dataset.ASINH_RANGE))
* dataset.ASINH_SCALE).astype(np.float32)
def to_tensors(sample, torch):
"""Pack a sample into tensors for :func:`suvi.align.align_stack` and the model.
The stack is *unaligned* here: alignment is elementwise math plus a warp, which
the GPU does in milliseconds and the CPU loader should not spend its budget on.
"""
from . import model as model_module
condition = torch.stack([
model_module.frame_conditioning(
sample["states"][i], bool(sample["same_satellite"][i]), float(sample["dts"][i])
)
for i in range(len(sample["states"]))
])
# Already in model space: the sampler works there throughout, so nothing to convert.
return {
"stack": torch.from_numpy(np.ascontiguousarray(sample["frames"])),
"condition": condition,
"dts": torch.from_numpy(np.ascontiguousarray(sample["dts"])),
"gains": torch.from_numpy(np.ascontiguousarray(sample["gains"])),
"offsets": torch.from_numpy(np.ascontiguousarray(sample["offsets"])),
"b0": torch.tensor(sample["b0"], dtype=torch.float32),
"radius": torch.tensor(sample["radius"], dtype=torch.float32),
"target": torch.from_numpy(np.ascontiguousarray(sample["target"])),
}