tartanimu-a3v21 / unified /context_model.py
LexHo's picture
a3v21_s42: frozen weights (S-fast + recording FiLM), predict.py, submission.csv (official 0.26306)
1b0d082 verified
Raw History Blame Contribute Delete
5.02 kB
#!/usr/bin/env python3
"""ContextIMUNet -- UnifiedIMUNet's trunk with a cross-window temporal mixer.
The single-window model sees one second of IMU and nothing else, which is enough
for a car or a walking human but not for a hovering drone: in the lowest
angular-rate quintile of drone windows the relative velocity error is 0.963, so
the network is barely beating a constant zero prediction. A hover simply does
not write the platform's velocity into one second of accelerometer and gyro --
the specific force is dominated by gravity and the residual signal is noise.
The neighbouring seconds do carry it, because a drone that is genuinely
stationary looks different over ten seconds from one drifting slowly, even
though the two are indistinguishable inside any single window. The official
baseline consumes seq_len=10 consecutive windows for this reason.
The trunk is kept identical to UnifiedIMUNet (same submodule names and shapes),
so a single-window checkpoint loads into it with strict=False and only the mixer
starts from scratch.
"""
from __future__ import annotations
import torch
import torch.nn as nn
from unified.model import ResidualBlock
class ContextIMUNet(nn.Module):
def __init__(self, mixer: str = "gru", max_len: int = 64, hidden: int = 128,
width: float = 1.0, dropout: float = 0.2, trunk: str = "cnn"):
super().__init__()
self.trunk_kind = trunk
# width scales the trunk's channels; 1.0 is the published TCMPIO trunk,
# which every result through round 5 used
c1, c2, c3, c4 = (max(8, int(round(c * width))) for c in (64, 96, 128, 192))
if trunk == "cnn":
self.encoder = nn.Sequential(
nn.Conv1d(6, c1, 7, padding=3, bias=False), nn.BatchNorm1d(c1), nn.GELU(),
ResidualBlock(c1, c1),
ResidualBlock(c1, c2, stride=2),
ResidualBlock(c2, c2, dilation=2),
ResidualBlock(c2, c3, stride=2, dilation=2),
ResidualBlock(c3, c3, dilation=4),
ResidualBlock(c3, c4, stride=2, dilation=4),
ResidualBlock(c4, c4, dilation=4),
)
elif trunk == "lstm":
# a recurrent trunk over the raw 200 frames, for comparison against the
# convolutional one; c4//2 per direction keeps the pooled width identical
self.encoder = nn.LSTM(6, c4 // 2, num_layers=2, batch_first=True,
bidirectional=True)
else:
raise ValueError(f"trunk must be 'cnn' or 'lstm', got {trunk!r}")
self.attention = nn.Conv1d(c4, 1, 1)
self.shared = nn.Sequential(nn.LayerNorm(3 * c4), nn.Linear(3 * c4, 256),
nn.GELU(), nn.Dropout(dropout))
self.velocity_head = nn.Linear(256, 3)
self.platform_head = nn.Linear(256, 4)
self.mixer_kind = mixer
self.max_len = max_len
if mixer == "gru":
self.mixer = nn.GRU(256, hidden, num_layers=2, batch_first=True, bidirectional=True)
self.mixer_proj = nn.Linear(2 * hidden, 256)
elif mixer == "attn":
layer = nn.TransformerEncoderLayer(256, nhead=4, dim_feedforward=512, batch_first=True)
self.mixer = nn.TransformerEncoder(layer, num_layers=2)
self.position = nn.Parameter(torch.zeros(max_len, 256))
nn.init.trunc_normal_(self.position, std=0.02)
else:
raise ValueError(f"mixer must be 'gru' or 'attn', got {mixer!r}")
self.mixer_norm = nn.LayerNorm(256)
def trunk(self, x):
"""(N, 6, 200) -> (N, 256); with trunk="cnn" this is UnifiedIMUNet's computation."""
if self.trunk_kind == "lstm":
x = self.encoder(x.transpose(1, 2))[0].transpose(1, 2) # (N, c4, 200)
else:
x = self.encoder(x)
weights = torch.softmax(self.attention(x), dim=-1)
features = torch.cat([(x * weights).sum(-1), x.mean(-1), x.std(-1, unbiased=False)], dim=1)
return self.shared(features)
def features_and_heads(self, x):
"""(B, K, 6, 200) -> (feature (B, K, 256), velocity (B, K, 3), logits (B, K, 4)).
The feature returned is the post-mixer one, so an auxiliary head sees the
same cross-window context the velocity head does.
"""
B, K = x.shape[:2]
features = self.trunk(x.reshape(B * K, *x.shape[2:])).view(B, K, 256)
if self.mixer_kind == "gru":
mixed = self.mixer_proj(self.mixer(features)[0])
else:
if K > self.max_len:
raise ValueError(f"segment of {K} windows exceeds max_len={self.max_len}")
mixed = self.mixer(features + self.position[:K])
features = self.mixer_norm(features + mixed)
return features, self.velocity_head(features), self.platform_head(features)
def forward(self, x):
_, velocity, platform_logits = self.features_and_heads(x)
return velocity, platform_logits