Skip to content

Commit 56115ed

Browse files
alxmrsclaude
andcommitted
Dual-perceiver encoder for cross-resolution fusion
Replace the single-stream PerceiverEncoder with a dual-perceiver architecture: separate Perceivers for prognostic and boundary streams, fused via concat→Linear projection. Because patch_extent is in degrees, both streams produce the same latent grid regardless of their spatial resolution, enabling cross-resolution forward passes (e.g. ¼° prog with 1° boundary). FOMO.forward_once now passes prog and boundary separately to the encoder instead of concatenating. 3D coordinates are appended to the prognostic stream only. Config: EncoderConfig gains a boundary_perceiver field (default depth=2, num_latents=64). PerceiverConfig.build outputs latent_dim (pooled) instead of out_channels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8f9b41a commit 56115ed

6 files changed

Lines changed: 412 additions & 150 deletions

File tree

src/ocean_emulators/config.py

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -387,12 +387,22 @@ class PerceiverConfig(BaseConfig):
387387
def build(
388388
self,
389389
in_channels: int,
390-
out_channels: int,
391390
max_patch_size: tuple[int, int],
392391
implementation: PerceiverImpl,
393392
) -> nn.Module:
394-
"""Build a regular Perceiver (used by the encoder)."""
395-
# This is not really a "frequency" but a maximum of the width appears to be reasonable from looking at the code.
393+
"""Build a Perceiver that maps 2-D patches to a pooled latent vector.
394+
395+
The Perceiver receives raw 2-D patches
396+
``(batch, ph, pw, input_channels)`` with internal Fourier position
397+
encoding. It returns ``(batch, latent_dim)`` after mean-pooling
398+
its latent vectors.
399+
400+
Args:
401+
in_channels: Number of input channels per patch pixel.
402+
max_patch_size: Largest ``(ph, pw)`` across data sources,
403+
used to set the Fourier frequency range.
404+
implementation: Which Perceiver backend to use.
405+
"""
396406
max_freq = max(*max_patch_size)
397407

398408
if _use_flash(implementation):
@@ -411,7 +421,7 @@ def build(
411421
latent_rotary_emb_dim=max_freq,
412422
depth=self.depth,
413423
input_dim=in_channels,
414-
output_dim=out_channels,
424+
output_dim=self.latent_dim,
415425
output_mode="average",
416426
latent_dim=self.latent_dim,
417427
num_latents=self.num_latents,
@@ -427,7 +437,7 @@ def build(
427437
depth=self.depth,
428438
input_axis=2,
429439
input_channels=in_channels,
430-
num_classes=out_channels,
440+
num_classes=self.latent_dim,
431441
latent_dim=self.latent_dim,
432442
num_latents=self.num_latents,
433443
weight_tie_layers=True,
@@ -504,23 +514,32 @@ def _flash_import_error() -> ValueError:
504514

505515
class EncoderConfig(BaseConfig):
506516
perceiver: PerceiverConfig = PerceiverConfig()
517+
boundary_perceiver: PerceiverConfig = PerceiverConfig(
518+
depth=2,
519+
num_latents=64,
520+
)
507521

508522
def build(
509523
self,
510-
in_channels: int,
524+
prog_channels: int,
525+
boundary_channels: int,
511526
out_channels: int,
512527
patch_extent: tuple[float, float],
513-
max_lat_size: int,
514-
max_lon_size: int,
528+
max_patch_size: tuple[int, int],
515529
implementation: PerceiverImpl,
516530
) -> PerceiverEncoder:
517-
max_patch_size = patch_from(patch_extent, max_lat_size, max_lon_size)
518531
return PerceiverEncoder(
519-
in_channels=in_channels,
532+
prog_channels=prog_channels,
533+
boundary_channels=boundary_channels,
520534
out_channels=out_channels,
535+
prog_latent_dim=self.perceiver.latent_dim,
536+
boundary_latent_dim=self.boundary_perceiver.latent_dim,
521537
patch_extent=patch_extent,
522538
perceiver=self.perceiver.build(
523-
in_channels, out_channels, max_patch_size, implementation
539+
prog_channels, max_patch_size, implementation
540+
),
541+
boundary_perceiver=self.boundary_perceiver.build(
542+
boundary_channels, max_patch_size, implementation
524543
),
525544
)
526545

@@ -769,28 +788,32 @@ def build(
769788
assert len(self.patch_extent) == 2, "patch_extent must be a pair of floats."
770789
extent = self.patch_extent[0], self.patch_extent[1]
771790

772-
all_grid_sizes = [s.grid_size for s in srcs]
773-
max_lat_size, max_lon_size = (
774-
max(g[0] for g in all_grid_sizes),
775-
max(g[1] for g in all_grid_sizes),
776-
)
777-
778791
impl = self.perceiver_implementation
779792
if _use_flash(impl) and not self.use_bfloat16:
780793
raise ValueError(
781794
"Perceiver implementation resolves to flash attention. "
782795
"Please set `use_bfloat16=True` or `perceiver_implementation='naive'`."
783796
)
784797

785-
in_channels = prog_channels + boundary_channels
786-
total_in_channels = in_channels + (3 if self.add_3d_coordinates else 0)
798+
# 3D coordinates are appended to the prognostic stream only.
799+
encoder_prog_channels = prog_channels + (3 if self.add_3d_coordinates else 0)
800+
801+
# Compute the largest patch size (in pixels) across all data source
802+
# resolutions. Used to set the Perceiver's Fourier frequency range.
803+
all_grid_sizes = [s.grid_size for s in srcs]
804+
max_ph, max_pw = 0, 0
805+
for grid_h, grid_w in all_grid_sizes:
806+
ph, pw = patch_from(extent, grid_h, grid_w)
807+
max_ph = max(max_ph, ph)
808+
max_pw = max(max_pw, pw)
809+
max_patch_size = (max_ph, max_pw)
787810

788811
encoder = self.encoder.build(
789-
total_in_channels,
812+
encoder_prog_channels,
813+
boundary_channels,
790814
self.embedding_dim,
791815
extent,
792-
max_lat_size,
793-
max_lon_size,
816+
max_patch_size,
794817
impl,
795818
)
796819
processor = self.processor.build(
@@ -806,8 +829,9 @@ def build(
806829
)
807830

808831
add_3d_coordinates = Concat3dCoordinates() if self.add_3d_coordinates else None
832+
# TODO(alxmrs): `in_channels` isn't used anywhere :/ Consider removing from base model.
809833
return FOMO(
810-
in_channels=total_in_channels,
834+
in_channels=encoder_prog_channels + boundary_channels,
811835
out_channels=out_channels,
812836
pred_residuals=self.pred_residuals,
813837
last_kernel_size=self.last_kernel_size,

src/ocean_emulators/models/fomo.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,14 @@ def __init__(
8989
def forward_once(
9090
self, prognostic: Prognostic, boundary: Boundary, ctx: GridContext
9191
) -> Prognostic:
92-
# Prognostic and boundary are carried as separate tensors through the
93-
# data pipeline, but this encoder still expects a single concatenated
94-
# input. The dual-perceiver encoder that fuses them at the token level
95-
# (enabling cross-resolution) lands in a follow-up PR.
96-
fts = torch.cat((prognostic, boundary), dim=1)
9792
with autocast(enabled=self.use_bfloat16, dtype=torch.bfloat16):
93+
# 3D coordinates describe the grid the prognostic tensor sits on.
9894
if self.maybe_add_3d_coordinates is not None:
99-
fts = self.maybe_add_3d_coordinates(fts, ctx.input_resolution_cpu)
100-
fts = self.encoder(fts, ctx.input_resolution_cpu)
95+
prognostic = self.maybe_add_3d_coordinates(
96+
prognostic, ctx.input_resolution_cpu
97+
)
98+
99+
fts = self.encoder(prognostic, boundary, ctx.input_resolution_cpu)
101100
fts = self.processor(fts)
102101

103102
# TODO(alxmrs): When the output resolution differs from the input (i.e. in a "mix" schedule), we cannot use

src/ocean_emulators/models/modules/encoder.py

Lines changed: 105 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
# - https://github.com/microsoft/aurora/blob/main/aurora/model/encoder.py
44
# - https://github.com/lucidrains/vit-pytorch
55

6+
67
import torch
78
from aurora.model.fourier import pos_expansion, scale_expansion
89
from aurora.model.posencoding import pos_scale_enc
910
from einops import rearrange
1011
from jaxtyping import Float
1112
from torch import nn
1213

13-
from ocean_emulators.constants import Input, Lat, Lon
14+
from ocean_emulators.constants import Boundary, Lat, Lon, Prognostic
1415

1516

1617
def patch_from(
@@ -28,26 +29,34 @@ def patch_from(
2829

2930

3031
class PerceiverEncoder(nn.Module):
31-
"""A perceiver-based encoder for Samudra's flattened data (a whole column of the ocean, with history).
32+
"""A dual-perceiver encoder that fuses prognostic and boundary streams.
33+
34+
Each stream gets its own Perceiver. The prognostic Perceiver is the
35+
primary workhorse; the boundary Perceiver is typically configured to be
36+
lightweight (fewer latents, shallower depth). Both operate on 2-D
37+
spatial patches with ``input_axis=2``, so they get native Fourier
38+
position encoding within each patch for free.
3239
33-
We adopt some of Aurora's positional encodings[1], which uses log-spaced fourier features with geometry-informed
34-
wavelengths. These encode 2d positions (the average latitude and longitude of each patch) as well as grid cell area
35-
(measured in km^2) for each token before it enters the processor.
40+
Because ``patch_extent`` is specified in degrees, both streams produce
41+
the **same latent grid** regardless of their spatial resolution.
3642
37-
> Note: We assume that data along the lat/lon coordinates are positioned at the center of each grid point! Please
38-
> ensure this is the case at the data processing time.
43+
After mean-pooling each Perceiver's latents independently, the two
44+
representations are concatenated and projected back to ``latent_dim``
45+
via a learned linear layer.
3946
40-
This encoder is designed to make the same number of patches with the same spatial extents across different scales
41-
of input data (input data may vary in resolution of lat/lng grid). To accomplish this with a single perceiver model,
42-
our `forward` call requires supplementary information: the resolution (a pair of Lat/Lon tensors), which is used to
43-
make consistent positional encodings for patches across different scales. While higher resolution scales will
44-
contain more data per patch, the patch will refer to the same physical area on Earth as all other scales.
47+
Patch-level positional and scale encodings (Aurora-style Fourier
48+
features [1]) are computed on the prognostic (output) grid and added
49+
after fusion.
4550
4651
Args:
47-
in_channels (int): the number of input channels (roughly: time x variable x (surface + depths)).
48-
out_channels (int): size of the latent dimension (aka, the embedding dimension).
49-
patch_extent (tuple[float, float]): spatial extent of each patch measured in degrees of lat/lon.
50-
perceiver (nn.Module): the perceiver module implementation to use.
52+
prog_channels: Number of prognostic input channels.
53+
boundary_channels: Number of boundary input channels.
54+
out_channels: Size of the output embedding dimension.
55+
prog_latent_dim: Output dimension of the prognostic Perceiver.
56+
boundary_latent_dim: Output dimension of the boundary Perceiver.
57+
patch_extent: Spatial extent of each patch in degrees ``(lat, lon)``.
58+
perceiver: Perceiver module for the prognostic stream.
59+
boundary_perceiver: Perceiver module for the boundary stream.
5160
5261
References:
5362
[1]: https://ar5iv.labs.arxiv.org/html/2405.13063#A2.SS4
@@ -56,56 +65,102 @@ class PerceiverEncoder(nn.Module):
5665
# TODO(alxmrs): Implement gradient checkpointing
5766
def __init__(
5867
self,
59-
in_channels: int,
68+
prog_channels: int,
69+
boundary_channels: int,
6070
out_channels: int,
71+
prog_latent_dim: int,
72+
boundary_latent_dim: int,
6173
patch_extent: tuple[float, float],
6274
perceiver: nn.Module,
75+
boundary_perceiver: nn.Module,
6376
) -> None:
6477
super().__init__()
65-
self.in_channels = in_channels
66-
self.out_channels: int = out_channels # aka, `embed_dim`.
78+
self.prog_channels = prog_channels
79+
self.boundary_channels = boundary_channels
80+
self.out_channels: int = out_channels
6781
self.patch_extent = patch_extent
6882
self.perceiver = perceiver
83+
self.boundary_perceiver = boundary_perceiver
84+
85+
# Fuse the two Perceiver outputs and project to embedding dim.
86+
self.fusion_proj = nn.Linear(
87+
prog_latent_dim + boundary_latent_dim, out_channels
88+
)
89+
6990
# TODO(#451): The input to these position and scale linear units could be a hparam.
7091
self.pos_embed = nn.Linear(self.out_channels, self.out_channels)
7192
self.scale_embed = nn.Linear(self.out_channels, self.out_channels)
7293

94+
def _patchify_params(
95+
self, shape: torch.Size, expected_channels: int
96+
) -> tuple[int, int, int, int]:
97+
"""Validate channels and compute patch / latent-grid dims.
98+
99+
Returns ``(ph, pw, lat_h, lat_w)``.
100+
"""
101+
_, v, h, w = shape
102+
assert v == expected_channels, (
103+
f"Expected {expected_channels} channels, got {v}."
104+
)
105+
ph, pw = patch_from(self.patch_extent, h, w)
106+
assert h % ph == 0, f"{h} % {ph} != 0."
107+
assert w % pw == 0, f"{w} % {pw} != 0."
108+
return ph, pw, h // ph, w // pw
109+
73110
def forward(
74-
self, x: Input, resolution: tuple[Lat, Lon]
75-
) -> Float[torch.Tensor, "batch {self.embed_dim} h w"]:
76-
_, V, H, W = x.shape
77-
lat, lon = resolution
78-
patch_h, patch_w = patch_from(self.patch_extent, H, W)
79-
# V is a cross product of variable, level (encoded in vars), and time (has history).
80-
assert V == self.in_channels
81-
# Ensure patch_size is appropriate for the data.
82-
assert H % patch_h == 0, f"{H} % {patch_h} != 0."
83-
assert W % patch_w == 0, f"{W} % {patch_w} != 0."
84-
85-
# Perceiver experiment ideas:
86-
# 1. leave it as it is: treating each pixel as a token -- i.e. all channels (includes depths) per pixel
87-
# 2. change to original plan, where each float is its own token
88-
# 3. Add a third dim -- ph pw d v -- so each spatial position is a token
89-
x = rearrange(
90-
x,
111+
self,
112+
prog: Prognostic,
113+
boundary: Boundary,
114+
prog_res: tuple[Lat, Lon],
115+
) -> Float[torch.Tensor, "batch {self.out_channels} h w"]:
116+
patch_h, patch_w, lat_h, lat_w = self._patchify_params(
117+
prog.shape, self.prog_channels
118+
)
119+
b_patch_h, b_patch_w, b_lat_h, b_lat_w = self._patchify_params(
120+
boundary.shape, self.boundary_channels
121+
)
122+
assert lat_h == b_lat_h and lat_w == b_lat_w, (
123+
f"Latent grid mismatch: prog ({lat_h}, {lat_w}) vs "
124+
f"boundary ({b_lat_h}, {b_lat_w}). Check that patch_extent "
125+
f"divides both grids evenly."
126+
)
127+
128+
# --- Prognostic stream: 2-D patches → Perceiver ---
129+
prog_patches = rearrange(
130+
prog,
91131
"b v (h ph) (w pw) -> (b h w) ph pw v",
92132
ph=patch_h,
93133
pw=patch_w,
94134
)
95-
# NB(alxmrs): This is includes a mean and LayerNorm before linear projection!
96-
x = self.perceiver(x) # (B_H_W, ..., V) -> (B_H_W, out_channels)
97-
98-
# Make `x` amenable to adding position + scale encoding
99-
x = rearrange(
100-
x,
101-
"(b h w) l -> b (h w) l ",
102-
h=(H // patch_h),
103-
w=(W // patch_w),
104-
)
135+
prog_pooled = self.perceiver(prog_patches) # (B_HW, latent_dim)
105136

106-
# Calculate and add positional + scale encoding
137+
# --- Boundary stream: 2-D patches → boundary Perceiver ---
138+
boundary_patches = rearrange(
139+
boundary,
140+
"b v (h ph) (w pw) -> (b h w) ph pw v",
141+
ph=b_patch_h,
142+
pw=b_patch_w,
143+
)
144+
boundary_pooled = self.boundary_perceiver(
145+
boundary_patches
146+
) # (B_HW, latent_dim)
147+
148+
# TODO(alxmrs): Linear fusion may not be a strong enough way to encode the
149+
# boundary signal. If this doesn't prove effective, consider using a
150+
# PerceiverIO model to cross-attend the prognostic and boundary latents.
151+
# For this, we could simply add a PerceiverIO after the two above perceivers,
152+
# or replace the boundary perceiver with a PerceiverIO that cross attends the
153+
# boundary input with the prognostic latents.
154+
# --- Fusion: concat pooled representations, project ---
155+
x = self.fusion_proj(
156+
torch.cat([prog_pooled, boundary_pooled], dim=-1)
157+
) # (B_HW, out_channels)
158+
159+
# --- Patch-level positional + scale encoding ---
160+
x = rearrange(x, "(b h w) l -> b (h w) l", h=lat_h, w=lat_w)
161+
lat, lon = prog_res
107162
pos_encode, scale_encode = pos_scale_enc(
108-
self.out_channels, # aka "embed_dim"
163+
self.out_channels,
109164
lat,
110165
lon,
111166
(patch_h, patch_w),
@@ -122,12 +177,7 @@ def forward(
122177
).unsqueeze(0)
123178
x = x + pos_encoding + scale_encoding
124179

125-
# Unpack spatial channels, move channel dimension to correct location.
126-
x = rearrange(
127-
x,
128-
"b (h w) l -> b l h w",
129-
h=(H // patch_h),
130-
w=(W // patch_w),
131-
)
180+
# --- Unpack spatial dims, move channel dim to standard location ---
181+
x = rearrange(x, "b (h w) l -> b l h w", h=lat_h, w=lat_w)
132182

133183
return x

0 commit comments

Comments
 (0)