Download unified/context_model.py from LexHo/tartanimu-a3v21: direct link, hf CLI and curl.
- Browser
- Download file 5.02 kB
-
https://huggingface.co/LexHo/tartanimu-a3v21/resolve/main/unified/context_model.py
- Command line
-
hf download hf://LexHo/tartanimu-a3v21/unified/context_model.py
-
curl -L -o context_model.py https://huggingface.co/LexHo/tartanimu-a3v21/resolve/main/unified/context_model.py
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 | |