33# - https://github.com/microsoft/aurora/blob/main/aurora/model/encoder.py
44# - https://github.com/lucidrains/vit-pytorch
55
6+
67import torch
78from aurora .model .fourier import pos_expansion , scale_expansion
89from aurora .model .posencoding import pos_scale_enc
910from einops import rearrange
1011from jaxtyping import Float
1112from 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
1617def patch_from (
@@ -28,26 +29,34 @@ def patch_from(
2829
2930
3031class 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