| """ |
| DBFT: Deflection-Basin Fusion Transformer for FWD critical strain prediction |
| ============================================================================= |
| Predicts the two critical pavement responses from Falling Weight Deflectometer |
| (FWD) measurements: |
| - AC : horizontal tensile strain at the bottom of the asphalt layer |
| - Subgrade.2 : vertical compressive strain at the top of the subgrade |
| |
| Inputs (field-measurable only — no layer moduli, avoiding backcalculation): |
| - Deflection basin D0..D1800 (9 geophones, treated as a spatial sequence) |
| - Layer thicknesses (Asphalt, Base, Subbase) |
| |
| Architecture (novel elements): |
| 1. Continuous sensor-offset positional encoding: Fourier features of the |
| physical geophone offset (mm), so the model knows the true basin geometry |
| and generalizes to arbitrary sensor arrays. |
| 2. Physics-informed basin tokens: SCI, BDI, BCI, AREA, AUPP indices are |
| embedded as extra tokens alongside raw deflections. |
| 3. Thickness encoder branch producing (a) memory tokens and (b) FiLM |
| (feature-wise linear modulation) parameters applied to the basin tokens. |
| 4. Strain-query transformer decoder: one learnable query per target strain |
| cross-attends over the fused basin+thickness memory (set-to-vector |
| decoding), giving per-target attention maps for interpretability. |
| """ |
|
|
| import math |
| import numpy as np |
| import pandas as pd |
| import torch |
| import torch.nn as nn |
|
|
| SENSOR_OFFSETS_MM = np.array([0, 200, 300, 450, 600, 900, 1200, 1500, 1800], dtype=np.float32) |
| DEFLECTION_COLS = ["D0", "D200", "D300", "D450", "D600", "D900", "D1200", "D1500", "D1800"] |
| THICKNESS_COLS = ["Asphalt", "Base", "Subbase"] |
| TARGET_COLS = {"AC_tensile": "AC", "Subgrade_compressive": "Subgrade.2"} |
|
|
|
|
| |
| |
| |
| def basin_indices(D: np.ndarray) -> np.ndarray: |
| """D: (N, 9) deflections at SENSOR_OFFSETS_MM. Returns (N, 5) indices.""" |
| d0, d200, d300, d450, d600, d900, d1200, d1500, d1800 = D.T |
| sci = d0 - d300 |
| bdi = d300 - d600 |
| bci = d600 - d900 |
| |
| area = 6.0 * (1 + 2 * d300 / d0 + 2 * d600 / d0 + d900 / d0) |
| |
| aupp = (5 * d0 - 2 * d300 - 2 * d600 - d900) / 2.0 |
| return np.stack([sci, bdi, bci, area, aupp], axis=1) |
|
|
|
|
| |
| |
| |
| class SensorOffsetEncoding(nn.Module): |
| """Fourier features of the physical geophone offset (mm), projected to d_model. |
| Unlike integer positional encoding, this respects the true non-uniform |
| basin geometry (0,200,300,450,...) and supports arbitrary sensor arrays.""" |
|
|
| def __init__(self, d_model: int, n_freq: int = 16, max_offset: float = 2000.0): |
| super().__init__() |
| freqs = torch.exp(torch.linspace(math.log(1.0), math.log(max_offset), n_freq)) |
| self.register_buffer("freqs", freqs) |
| self.proj = nn.Linear(2 * n_freq, d_model) |
|
|
| def forward(self, offsets_mm: torch.Tensor) -> torch.Tensor: |
| |
| x = offsets_mm.unsqueeze(-1) / self.freqs |
| feats = torch.cat([torch.sin(x), torch.cos(x)], dim=-1) |
| return self.proj(feats) |
|
|
|
|
| class FiLM(nn.Module): |
| """Feature-wise linear modulation of basin tokens by the thickness code.""" |
|
|
| def __init__(self, cond_dim: int, d_model: int): |
| super().__init__() |
| self.to_gamma_beta = nn.Linear(cond_dim, 2 * d_model) |
|
|
| def forward(self, tokens: torch.Tensor, cond: torch.Tensor) -> torch.Tensor: |
| gamma, beta = self.to_gamma_beta(cond).chunk(2, dim=-1) |
| return tokens * (1 + gamma.unsqueeze(1)) + beta.unsqueeze(1) |
|
|
|
|
| |
| |
| |
| class DBFT(nn.Module): |
| def __init__( |
| self, |
| d_model: int = 64, |
| n_heads: int = 4, |
| n_encoder_layers: int = 3, |
| n_decoder_layers: int = 2, |
| d_ff: int = 128, |
| dropout: float = 0.10, |
| n_targets: int = 2, |
| n_thickness: int = 3, |
| n_indices: int = 5, |
| use_film: bool = True, |
| use_indices: bool = True, |
| use_thickness_memory: bool = True, |
| ): |
| super().__init__() |
| self.use_film = use_film |
| self.use_indices = use_indices |
| self.use_thickness_memory = use_thickness_memory |
|
|
| |
| self.deflection_embed = nn.Linear(1, d_model) |
| self.pos_enc = SensorOffsetEncoding(d_model) |
| self.register_buffer("offsets", torch.tensor(SENSOR_OFFSETS_MM)) |
|
|
| |
| self.index_embed = nn.Linear(1, d_model) |
| self.index_type_embed = nn.Parameter(torch.randn(n_indices, d_model) * 0.02) |
|
|
| enc_layer = nn.TransformerEncoderLayer( |
| d_model, n_heads, d_ff, dropout, activation="gelu", |
| batch_first=True, norm_first=True, |
| ) |
| self.basin_encoder = nn.TransformerEncoder(enc_layer, n_encoder_layers) |
|
|
| |
| self.thickness_encoder = nn.Sequential( |
| nn.Linear(n_thickness, d_model), nn.GELU(), |
| nn.Linear(d_model, d_model), nn.GELU(), |
| ) |
| self.film = FiLM(d_model, d_model) |
| self.thickness_token_proj = nn.Linear(d_model, d_model) |
|
|
| |
| self.strain_queries = nn.Parameter(torch.randn(n_targets, d_model) * 0.02) |
| dec_layer = nn.TransformerDecoderLayer( |
| d_model, n_heads, d_ff, dropout, activation="gelu", |
| batch_first=True, norm_first=True, |
| ) |
| self.fusion_decoder = nn.TransformerDecoder(dec_layer, n_decoder_layers) |
|
|
| self.head = nn.Sequential( |
| nn.LayerNorm(d_model), nn.Linear(d_model, d_model), nn.GELU(), |
| nn.Linear(d_model, 1), |
| ) |
|
|
| def forward(self, deflections, indices, thickness): |
| """deflections (B,9), indices (B,5), thickness (B,3) — all standardized.""" |
| B = deflections.shape[0] |
|
|
| |
| tok = self.deflection_embed(deflections.unsqueeze(-1)) |
| tok = tok + self.pos_enc(self.offsets).unsqueeze(0) |
|
|
| if self.use_indices: |
| idx_tok = self.index_embed(indices.unsqueeze(-1)) + self.index_type_embed |
| tok = torch.cat([tok, idx_tok], dim=1) |
|
|
| |
| t_code = self.thickness_encoder(thickness) |
| if self.use_film: |
| tok = self.film(tok, t_code) |
|
|
| memory = self.basin_encoder(tok) |
| if self.use_thickness_memory: |
| memory = torch.cat([memory, self.thickness_token_proj(t_code).unsqueeze(1)], dim=1) |
|
|
| queries = self.strain_queries.unsqueeze(0).expand(B, -1, -1) |
| decoded = self.fusion_decoder(queries, memory) |
| return self.head(decoded).squeeze(-1) |
|
|
| @torch.no_grad() |
| def attention_map(self, deflections, indices, thickness): |
| """Cross-attention weights of each strain query over memory tokens |
| (last decoder layer), for interpretability.""" |
| self.eval() |
| B = deflections.shape[0] |
| tok = self.deflection_embed(deflections.unsqueeze(-1)) |
| tok = tok + self.pos_enc(self.offsets).unsqueeze(0) |
| if self.use_indices: |
| idx_tok = self.index_embed(indices.unsqueeze(-1)) + self.index_type_embed |
| tok = torch.cat([tok, idx_tok], dim=1) |
| t_code = self.thickness_encoder(thickness) |
| if self.use_film: |
| tok = self.film(tok, t_code) |
| memory = self.basin_encoder(tok) |
| if self.use_thickness_memory: |
| memory = torch.cat([memory, self.thickness_token_proj(t_code).unsqueeze(1)], dim=1) |
|
|
| x = self.strain_queries.unsqueeze(0).expand(B, -1, -1) |
| attn_out = None |
| for i, layer in enumerate(self.fusion_decoder.layers): |
| q = layer.norm1(x) |
| x = x + layer.dropout1(layer.self_attn(q, q, q, need_weights=False)[0]) |
| q2 = layer.norm2(x) |
| out, w = layer.multihead_attn(q2, memory, memory, |
| need_weights=True, average_attn_weights=True) |
| if i == len(self.fusion_decoder.layers) - 1: |
| attn_out = w |
| x = x + layer.dropout2(out) |
| x = x + layer._ff_block(layer.norm3(x)) |
| return attn_out |
|
|
|
|
| |
| |
| |
| def load_dataset(path="strain_result.xlsx"): |
| df = pd.read_csv(path) if str(path).endswith(".csv") else pd.read_excel(path) |
| D = df[DEFLECTION_COLS].to_numpy(np.float32) |
| H = df[THICKNESS_COLS].to_numpy(np.float32) |
| Y = df[list(TARGET_COLS.values())].to_numpy(np.float32) |
| I = basin_indices(D).astype(np.float32) |
| return D, I, H, Y, df |
|
|
|
|
| class Standardizer: |
| def fit(self, x): |
| self.mean = x.mean(axis=0, keepdims=True) |
| self.std = x.std(axis=0, keepdims=True) + 1e-8 |
| return self |
|
|
| def transform(self, x): |
| return (x - self.mean) / self.std |
|
|
| def inverse(self, x): |
| return x * self.std + self.mean |
|
|