File size: 15,394 Bytes
f9c2f92 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 | """Paper/checkpoint-faithful Do-PFN input encoder for PFN Studio.
Paste this entire file into the project block registered as
``dopfn_input_encoder``. It replaces BOTH ``grid_preprocessor`` and
``tabular_cell_embedder``.
Expected raw grid
-----------------
``x`` has shape ``(B, R, C)`` and columns ``[treatment, covariates..., y]``.
The first ``single_eval_pos`` rows are context rows. Query-row ``y`` values
must be NaN so that the answer cannot leak into the model.
Output
------
``(B, R, G + 1, d_model)`` where ``G`` is the number of X feature groups and
the LAST token is the separately encoded Y token. With the released Do-PFN
setting ``features_per_group=85`` and this prior's <= 7 X columns, ``G=1`` and
the output is ``(B, R, 2, 192)``.
The implementation follows the encoder serialized in ``dopfn_model.pkl``:
* X: NaN/Inf indicator -> context-mean imputation -> context-only
normalization and [-100, 100] clipping -> pad/group to 85 -> concatenate
value and missingness channels -> Linear(170, 192).
* Y: externally context-standardized by the prior/inference wrapper because
the checkpoint has ``transform_target=True``; then NaN/Inf indicator ->
context-mean imputation -> concatenate value and missingness channels ->
Linear(2, 192).
* The encoded Y is appended as the last feature token.
The serialized encoder contains a ColumnMarkerEncoderStep, but its final
LinearInputEncoderStep consumes only ``main`` and ``nan_indicators``. Adding
learned treatment/covariate/outcome role embeddings would therefore introduce
parameters that are not present in the released checkpoint. Treatment is
kept in the first fixed X slot, matching the released input order.
"""
from __future__ import annotations
import json
from typing import Any
from pfnstudio_core.registry import register_block
@register_block("dopfn_input_encoder")
class DoPFNInputEncoder:
"""Separate X/Y encoder used before the Do-PFN axial transformer stack."""
# PFN Studio threads batch['n_ctx'] to blocks that opt into this contract.
needs_single_eval_pos: bool = True
NAN_INDICATOR = -2.0
POS_INF_INDICATOR = 2.0
NEG_INF_INDICATOR = 4.0
def __init__(
self,
d_model: int = 192,
num_cols: int = 8,
features_per_group: int = 85,
normalize_by_used_features: bool = True,
clip_value: float = 100.0,
eps: float = 1.0e-6,
bias: bool = True,
seed: int = 42,
log_first_n: int = 3,
**_: Any,
) -> None:
import torch
import torch.nn as nn
self.d_model = int(d_model)
self.num_cols = int(num_cols)
self.features_per_group = int(features_per_group)
self.normalize_by_used_features = bool(normalize_by_used_features)
self.clip_value = float(clip_value)
self.eps = float(eps)
self.log_first_n = max(0, int(log_first_n))
self._forward_calls = 0
if self.d_model <= 0:
raise ValueError(f"d_model must be positive; got {self.d_model}.")
if self.features_per_group <= 0:
raise ValueError(
"features_per_group must be positive; "
f"got {self.features_per_group}."
)
if self.clip_value <= 0.0:
raise ValueError(f"clip_value must be positive; got {self.clip_value}.")
if self.eps <= 0.0:
raise ValueError(f"eps must be positive; got {self.eps}.")
# Use a private RNG scope: deterministic initialization without changing
# the seed used by blocks constructed after this project block.
with torch.random.fork_rng(devices=[]):
torch.manual_seed(int(seed))
self.x_encoder = nn.Linear(
2 * self.features_per_group,
self.d_model,
bias=bool(bias),
)
self.y_encoder = nn.Linear(2, self.d_model, bias=bool(bias))
def __call__(
self,
x: Any,
*,
single_eval_pos: int | None = None,
) -> Any:
return self.forward(x, single_eval_pos=single_eval_pos)
def forward(
self,
x: Any,
*,
single_eval_pos: int | None = None,
) -> Any:
import torch
if not torch.is_tensor(x):
raise TypeError(
"dopfn_input_encoder expects a torch.Tensor with shape "
f"(B, R, C); got {type(x).__name__}."
)
if x.dim() != 3:
raise ValueError(
"dopfn_input_encoder must receive the RAW grid (B, R, C). "
"Remove grid_preprocessor and tabular_cell_embedder from the "
f"model. Received shape {tuple(x.shape)}."
)
batch_size, rows, cols = (int(v) for v in x.shape)
if cols < 2:
raise ValueError(
"Do-PFN input needs at least one X column and one final Y "
f"column; received C={cols}."
)
if single_eval_pos is None:
raise ValueError(
"dopfn_input_encoder requires single_eval_pos. Ensure the "
"prior returns batch['n_ctx']."
)
n_ctx = int(single_eval_pos)
if not 0 < n_ctx < rows:
raise ValueError(
f"single_eval_pos must be in [1, R-1]; got {n_ctx} for R={rows}."
)
if not x.is_floating_point():
x = x.float()
# The prior emits Y as its final column, but PFN Studio may right-pad a
# later, narrower variable-feature batch to a width seen on an earlier
# step. Those zero padding columns then appear *after* Y. Locate Y by
# the Do-PFN masking contract instead: it is the one column that is
# finite in context and non-finite for every query row.
y_col = self._locate_y_column(x, n_ctx)
if y_col <= 0:
raise ValueError(
f"Detected Y at column {y_col}; at least one treatment/X "
"column must precede it."
)
x_raw = x[:, :, :y_col]
y_raw = x[:, :, y_col : y_col + 1]
right_padding_cols = cols - y_col - 1
# A finite query Y would leak the answer. The released model appends
# missing Y rows internally when only context Y is supplied; Studio's
# packed grid represents the same thing with NaNs in the final column.
query_y_finite = torch.isfinite(y_raw[:, n_ctx:]).sum()
if int(query_y_finite.item()) != 0:
self._log(
"error",
"query_target_leakage",
query_finite_y=int(query_y_finite.item()),
n_ctx=n_ctx,
rows=rows,
)
raise ValueError(
"Query-row Y values must be NaN. Finite query targets would "
"leak the answers into the transformer."
)
# PerFeatureTransformer groups/pads X before running its sequential
# encoder. Do the same and fold each feature group into the batch axis
# while fitting row statistics.
x_feature_count = int(x_raw.shape[-1])
group_size = self.features_per_group
pad = (-x_feature_count) % group_size
if pad:
x_raw = torch.cat(
[x_raw, x_raw.new_zeros(batch_size, rows, pad)], dim=-1
)
n_groups = int(x_raw.shape[-1] // group_size)
x_grouped = (
x_raw.reshape(batch_size, rows, n_groups, group_size)
.permute(0, 2, 1, 3)
.reshape(batch_size * n_groups, rows, group_size)
)
x_indicator = self._nan_indicator(x_grouped)
x_clean, x_all_missing = self._mean_impute(x_grouped, n_ctx)
x_scaled_unclipped = self._normalize_x(x_clean, n_ctx)
x_clip_count = int(
(x_scaled_unclipped.abs() > self.clip_value).sum().item()
)
x_scaled = x_scaled_unclipped.clamp(
min=-self.clip_value,
max=self.clip_value,
)
# Checkpoint setting: normalize_by_used_features=True. A feature is
# "used" if any row differs from row zero. Padded zero features are not
# counted. This is VariableNumFeaturesEncoderStep's exact purpose.
if self.normalize_by_used_features:
used = (x_scaled[:, 1:, :] != x_scaled[:, :1, :]).any(dim=1)
used_count = used.sum(dim=-1).clamp(min=1).to(x_scaled.dtype)
scale = (float(group_size) / used_count).sqrt().view(-1, 1, 1)
x_scaled = x_scaled * scale
x_encoder_input = torch.cat([x_scaled, x_indicator], dim=-1)
embedded_x = self.x_encoder(x_encoder_input)
embedded_x = (
embedded_x.reshape(batch_size, n_groups, rows, self.d_model)
.permute(0, 2, 1, 3)
.contiguous()
)
# The released y_encoder is NanHandlingEncoderStep followed directly
# by LinearInputEncoderStep(2, 192); it does not normalize Y here.
y_indicator = self._nan_indicator(y_raw)
y_clean, y_all_missing = self._mean_impute(y_raw, n_ctx)
y_encoder_input = torch.cat([y_clean, y_indicator], dim=-1)
embedded_y = self.y_encoder(y_encoder_input).unsqueeze(2)
out = torch.cat([embedded_x, embedded_y], dim=2)
if not torch.isfinite(out).all():
finite = torch.isfinite(out)
self._log(
"error",
"non_finite_encoder_output",
shape=list(out.shape),
nan=int(torch.isnan(out).sum().item()),
posinf=int(torch.isposinf(out).sum().item()),
neginf=int(torch.isneginf(out).sum().item()),
finite=int(finite.sum().item()),
)
raise FloatingPointError(
"dopfn_input_encoder produced NaN/Inf; inspect the emitted "
"non_finite_encoder_output event."
)
self._forward_calls += 1
if self._forward_calls <= self.log_first_n:
self._log(
"info",
"forward_summary",
call=self._forward_calls,
input_shape=[batch_size, rows, cols],
expected_num_cols=self.num_cols,
detected_y_column=y_col,
ignored_right_padding_columns=right_padding_cols,
x_features=x_feature_count,
feature_groups=n_groups,
output_shape=list(out.shape),
n_ctx=n_ctx,
n_query=rows - n_ctx,
x_nonfinite=int((~torch.isfinite(x_grouped)).sum().item()),
y_nonfinite=int((~torch.isfinite(y_raw)).sum().item()),
x_all_missing_context_features=int(x_all_missing.sum().item()),
y_all_missing_context_features=int(y_all_missing.sum().item()),
standardized_values_clipped=x_clip_count,
max_abs_standardized_before_clip=self._finite_abs_max(
x_scaled_unclipped
),
max_abs_encoder_output=self._finite_abs_max(out),
)
return out
def _locate_y_column(self, value: Any, n_ctx: int) -> int:
import torch
context = value[:, :n_ctx, :]
query = value[:, n_ctx:, :]
# Y has observed context values and is masked for all query rows.
candidates = (
torch.isfinite(context).any(dim=1)
& (~torch.isfinite(query)).all(dim=1)
)
counts = candidates.sum(dim=1)
if not (counts == 1).all():
self._log(
"error",
"cannot_locate_y_column",
input_shape=list(value.shape),
n_ctx=n_ctx,
candidate_counts=[int(v) for v in counts.detach().cpu().tolist()],
candidate_columns=[
torch.nonzero(row, as_tuple=False).flatten().detach().cpu().tolist()
for row in candidates
],
)
raise ValueError(
"Could not uniquely locate the masked Y column. Expected "
"exactly one column that is finite in context and non-finite "
"for every query row."
)
indices = candidates.to(torch.int64).argmax(dim=1)
if not (indices == indices[0]).all():
detected = [int(v) for v in indices.detach().cpu().tolist()]
self._log(
"error",
"inconsistent_y_columns_in_batch",
detected_columns=detected,
)
raise ValueError(
"Samples in one batch have Y in different columns: "
f"{detected}. The prior batch must share one feature width."
)
return int(indices[0].item())
def _nan_indicator(self, value: Any) -> Any:
import torch
indicator = torch.zeros_like(value)
indicator = torch.where(
torch.isnan(value),
indicator.new_full((), self.NAN_INDICATOR),
indicator,
)
indicator = torch.where(
torch.isposinf(value),
indicator.new_full((), self.POS_INF_INDICATOR),
indicator,
)
indicator = torch.where(
torch.isneginf(value),
indicator.new_full((), self.NEG_INF_INDICATOR),
indicator,
)
return indicator
def _mean_impute(self, value: Any, n_ctx: int) -> tuple[Any, Any]:
import torch
context = value[:, :n_ctx]
finite = torch.isfinite(context)
counts = finite.sum(dim=1)
all_missing = counts == 0
safe = torch.where(finite, context, torch.zeros_like(context))
means = safe.sum(dim=1) / counts.clamp(min=1).to(value.dtype)
means = torch.where(all_missing, torch.zeros_like(means), means)
cleaned = torch.where(
torch.isfinite(value), value, means.unsqueeze(1).expand_as(value)
)
return cleaned, all_missing
def _normalize_x(self, value: Any, n_ctx: int) -> Any:
context = value[:, :n_ctx]
mean = context.mean(dim=1, keepdim=True)
if n_ctx == 1:
std = context.new_ones(context.shape[0], 1, context.shape[2])
else:
# Do-PFN's torch_nanstd uses the sample standard deviation (N-1)
# and normalize_data adds 1e-6 afterward.
std = context.std(dim=1, unbiased=True, keepdim=True) + self.eps
std = std.clamp_min(self.eps)
return (value - mean) / std
@staticmethod
def _finite_abs_max(value: Any) -> float | None:
import torch
finite = torch.isfinite(value)
if not finite.any():
return None
return float(value[finite].abs().max().item())
@staticmethod
def _log(level: str, message: str, **fields: Any) -> None:
# One JSON object per line integrates cleanly with PFN Studio's run log.
print(
json.dumps(
{
"event": "dopfn_input_encoder",
"level": level,
"message": message,
**fields,
},
sort_keys=True,
default=str,
),
flush=True,
)
|