LightPFN 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lightpfn/__init__.py +25 -0
- lightpfn/checkpoint.py +107 -0
- lightpfn/device.py +51 -0
- lightpfn/model/__init__.py +1 -0
- lightpfn/model/catstats.py +117 -0
- lightpfn/model/layers.py +190 -0
- lightpfn/model/lightpfn.py +593 -0
- lightpfn/pretrained.json +4 -0
- lightpfn/sklearn.py +406 -0
- lightpfn/vulkan/__init__.py +40 -0
- lightpfn/vulkan/engine.py +210 -0
- lightpfn/vulkan/kernels.py +457 -0
- lightpfn/vulkan/model.py +548 -0
- lightpfn-1.0.0.dist-info/METADATA +298 -0
- lightpfn-1.0.0.dist-info/RECORD +18 -0
- lightpfn-1.0.0.dist-info/WHEEL +4 -0
- lightpfn-1.0.0.dist-info/licenses/LICENSE +202 -0
- lightpfn-1.0.0.dist-info/licenses/NOTICE +11 -0
lightpfn/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""LightPFN: compact tabular inference, pretrained only on synthetic data."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
__all__ = ["Config", "LightPFN", "LightPFNClassifier", "load_model", "load_pretrained", "save_model", "__version__"]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def __getattr__(name):
|
|
8
|
+
if name in ("Config", "LightPFN"):
|
|
9
|
+
from lightpfn.model import lightpfn
|
|
10
|
+
value = getattr(lightpfn, name)
|
|
11
|
+
elif name == "LightPFNClassifier":
|
|
12
|
+
try:
|
|
13
|
+
from lightpfn.sklearn import LightPFNClassifier
|
|
14
|
+
except ModuleNotFoundError as exc:
|
|
15
|
+
if exc.name != "sklearn":
|
|
16
|
+
raise
|
|
17
|
+
raise ImportError("LightPFNClassifier requires scikit-learn: `pip install scikit-learn`.") from exc
|
|
18
|
+
value = LightPFNClassifier
|
|
19
|
+
elif name in ("load_model", "load_pretrained", "save_model"):
|
|
20
|
+
from lightpfn import checkpoint
|
|
21
|
+
value = getattr(checkpoint, name)
|
|
22
|
+
else:
|
|
23
|
+
raise AttributeError(name)
|
|
24
|
+
globals()[name] = value
|
|
25
|
+
return value
|
lightpfn/checkpoint.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Safe local checkpoints and revision-pinned Hugging Face downloads.
|
|
2
|
+
|
|
3
|
+
The release format is model.safetensors plus config.json. Legacy tensor/dict
|
|
4
|
+
checkpoints remain supported using PyTorch's restricted weights-only loader.
|
|
5
|
+
There is deliberately no fallback to unrestricted pickle.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
from importlib.resources import files
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import torch
|
|
15
|
+
|
|
16
|
+
from lightpfn.model.lightpfn import Config, LightPFN
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _safetensors():
|
|
20
|
+
try:
|
|
21
|
+
from safetensors.torch import load_file, save_file
|
|
22
|
+
except ImportError as exc:
|
|
23
|
+
raise ImportError("Safetensors weights require `pip install safetensors`.") from exc
|
|
24
|
+
return load_file, save_file
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _config(values):
|
|
28
|
+
if not isinstance(values, Mapping):
|
|
29
|
+
raise ValueError("Checkpoint config must be a mapping of architecture parameters.")
|
|
30
|
+
values = dict(values)
|
|
31
|
+
if "group_offsets" in values:
|
|
32
|
+
values["group_offsets"] = tuple(values["group_offsets"])
|
|
33
|
+
return Config(**values)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_model(path, device="cpu"):
|
|
37
|
+
"""Load a safetensors directory/file or a weights-only compatible .pt file.
|
|
38
|
+
|
|
39
|
+
Safetensors files need config.json in the same directory. Full training
|
|
40
|
+
checkpoints containing arbitrary Python objects are intentionally rejected.
|
|
41
|
+
"""
|
|
42
|
+
path = Path(path)
|
|
43
|
+
if path.is_dir():
|
|
44
|
+
path = path / "model.safetensors"
|
|
45
|
+
if path.suffix == ".safetensors":
|
|
46
|
+
load_file, _ = _safetensors()
|
|
47
|
+
config = json.loads(path.with_name("config.json").read_text(encoding="utf-8"))
|
|
48
|
+
state = load_file(str(path), device="cpu")
|
|
49
|
+
else:
|
|
50
|
+
checkpoint = torch.load(path, map_location="cpu", weights_only=True)
|
|
51
|
+
if not isinstance(checkpoint, Mapping) or "config" not in checkpoint:
|
|
52
|
+
raise ValueError("Checkpoint must contain 'config' and 'ema' or 'model' weights.")
|
|
53
|
+
config = checkpoint["config"]
|
|
54
|
+
state = checkpoint.get("ema", checkpoint.get("model"))
|
|
55
|
+
if not isinstance(state, Mapping) or not state or not all(
|
|
56
|
+
isinstance(key, str) and isinstance(value, torch.Tensor) for key, value in state.items()
|
|
57
|
+
):
|
|
58
|
+
raise ValueError("Checkpoint weights must be a nonempty tensor state dictionary.")
|
|
59
|
+
model = LightPFN(_config(config))
|
|
60
|
+
model.load_state_dict(state, strict=True)
|
|
61
|
+
return model.to(device).eval()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_model(model, directory):
|
|
65
|
+
"""Export unfused inference weights and JSON config, excluding optimizer state."""
|
|
66
|
+
if getattr(model, "is_folded", False):
|
|
67
|
+
raise ValueError("Export the original model; folded weights use a different layout.")
|
|
68
|
+
_, save_file = _safetensors()
|
|
69
|
+
directory = Path(directory)
|
|
70
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
state = {key: value.detach().cpu().contiguous().clone() for key, value in model.state_dict().items()}
|
|
72
|
+
save_file(state, str(directory / "model.safetensors"), metadata={"format": "pt"})
|
|
73
|
+
(directory / "config.json").write_text(json.dumps(asdict(model.cfg), indent=2) + "\n", encoding="utf-8")
|
|
74
|
+
return directory
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def pretrained_spec():
|
|
78
|
+
"""Return the packaged model repository and immutable revision."""
|
|
79
|
+
return json.loads(files("lightpfn").joinpath("pretrained.json").read_text(encoding="utf-8"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_pretrained(*, repo_id=None, revision=None, device="cpu", cache_dir=None, local_files_only=False):
|
|
83
|
+
"""Load the released model, or an explicit repo and revision using cached HF authentication.
|
|
84
|
+
|
|
85
|
+
A custom repository requires a revision. No remote Python code is executed.
|
|
86
|
+
For private repositories authenticate with `hf auth login` or HF_TOKEN.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
from huggingface_hub import hf_hub_download
|
|
90
|
+
except ImportError as exc:
|
|
91
|
+
raise ImportError("Hugging Face downloads require `pip install huggingface-hub`.") from exc
|
|
92
|
+
spec = pretrained_spec()
|
|
93
|
+
repo_id = spec["repo_id"] if repo_id is None else repo_id
|
|
94
|
+
if revision is None:
|
|
95
|
+
if repo_id != spec["repo_id"]:
|
|
96
|
+
raise ValueError("Provide revision when using a custom Hugging Face repository.")
|
|
97
|
+
revision = spec["revision"]
|
|
98
|
+
if not revision:
|
|
99
|
+
raise ValueError("No pretrained revision configured; provide a checkpoint or explicit revision.")
|
|
100
|
+
options = dict(repo_id=repo_id, revision=revision, cache_dir=cache_dir, local_files_only=local_files_only)
|
|
101
|
+
config_path = Path(hf_hub_download(filename="config.json", **options))
|
|
102
|
+
# Use the resolved commit from the snapshot path for both files, even when a
|
|
103
|
+
# caller explicitly chose a mutable branch or tag.
|
|
104
|
+
resolved = config_path.parent.name
|
|
105
|
+
options["revision"] = resolved
|
|
106
|
+
weights_path = Path(hf_hub_download(filename="model.safetensors", **options))
|
|
107
|
+
return load_model(weights_path, device=device)
|
lightpfn/device.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Inference device: "auto" takes torch's GPU backend when there is one (CUDA on NVIDIA, also ROCm builds of
|
|
2
|
+
torch, which use the same "cuda" device), otherwise a GPU with a Vulkan driver (AMD, Intel or NVIDIA,
|
|
3
|
+
through wgpu), otherwise the CPU. Each can be asked for explicitly: "cuda[:i]", "vulkan[:i]" (index into
|
|
4
|
+
lightpfn.vulkan.adapters()), "cpu". The environment variable LIGHTPFN_DEVICE replaces "auto" with any of
|
|
5
|
+
these, e.g. LIGHTPFN_DEVICE=cpu to keep a GPU free.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
import torch
|
|
11
|
+
|
|
12
|
+
DEVICE_ENV = "LIGHTPFN_DEVICE"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_device(device="auto"):
|
|
16
|
+
"""The device string LightPFNClassifier runs on: "cuda[:i]", "vulkan[:i]", "cpu" (or "mps")."""
|
|
17
|
+
device = "auto" if device is None else str(device).strip().lower()
|
|
18
|
+
if device == "auto":
|
|
19
|
+
device = os.environ.get(DEVICE_ENV, "").strip().lower() or "auto"
|
|
20
|
+
if device == "auto":
|
|
21
|
+
if torch.cuda.is_available():
|
|
22
|
+
return "cuda"
|
|
23
|
+
from lightpfn import vulkan
|
|
24
|
+
|
|
25
|
+
return "vulkan" if vulkan.is_available() else "cpu"
|
|
26
|
+
kind, _, index = device.partition(":")
|
|
27
|
+
if ":" in device and not index:
|
|
28
|
+
raise ValueError(f"device {device!r}: missing index after ':'")
|
|
29
|
+
if index and not index.isdigit():
|
|
30
|
+
raise ValueError(f"device {device!r}: the index after ':' must be a number")
|
|
31
|
+
if kind == "cpu":
|
|
32
|
+
return "cpu"
|
|
33
|
+
if kind == "cuda":
|
|
34
|
+
if not torch.cuda.is_available():
|
|
35
|
+
raise RuntimeError("device 'cuda' requested, but this torch build sees no CUDA/ROCm GPU")
|
|
36
|
+
return device
|
|
37
|
+
if kind == "vulkan":
|
|
38
|
+
from lightpfn import vulkan
|
|
39
|
+
|
|
40
|
+
if not vulkan.is_available(int(index) if index else None):
|
|
41
|
+
raise RuntimeError(f"device {device!r} requested, but no Vulkan adapter was found: it needs a GPU "
|
|
42
|
+
"driver with Vulkan and `pip install wgpu` (adapters: lightpfn.vulkan.adapters())")
|
|
43
|
+
return device
|
|
44
|
+
if kind == "mps":
|
|
45
|
+
return device
|
|
46
|
+
raise ValueError(f"unknown device {device!r}: use 'auto', 'cpu', 'cuda[:i]' or 'vulkan[:i]'")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def kind(device):
|
|
50
|
+
""""cpu", "cuda", "vulkan" or "mps" of a resolved device string."""
|
|
51
|
+
return str(device).partition(":")[0]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LightPFN architecture; the inference package does not import synthetic priors."""
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Category statistics of the context rows for the categorical adapter (Config.cat_adapter).
|
|
2
|
+
|
|
3
|
+
Every categorical cell receives the class distribution of its category among the context rows,
|
|
4
|
+
as a deviation from the class prior, plus count features. Query rows use every context row of
|
|
5
|
+
their category. A context row uses only the context rows that come before it in a random order
|
|
6
|
+
of the rows (CatBoost's ordered target statistics), so its own label never enters its features,
|
|
7
|
+
and no label is ever correlated with its own statistics beyond the true dependence (leave-one-out
|
|
8
|
+
statistics are: the model collapsed on them in our tests). The count of rows the
|
|
9
|
+
statistics rest on is a feature, so the model can weigh a context row's noisier estimate.
|
|
10
|
+
|
|
11
|
+
Missing values form one category of their own. An unseen category gets zero features.
|
|
12
|
+
Everything is vectorized over tasks and columns; only the categorical columns are processed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
|
|
17
|
+
import torch
|
|
18
|
+
import torch.nn.functional as F
|
|
19
|
+
|
|
20
|
+
N_COUNT = 3 # log1p(rows the statistics use), log1p(other rows of the category), their fraction of the rows
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _keys(x):
|
|
24
|
+
"""Category keys: the values, with missing values as one key that sorts last."""
|
|
25
|
+
x = x.float()
|
|
26
|
+
return torch.where(torch.isnan(x), torch.full_like(x, math.inf), x)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _features(counts, n_used, n_freq, prior, n, smooth):
|
|
30
|
+
"""counts (..., C) class counts of the rows used, n_used/n_freq (...) row counts, prior (B, C)
|
|
31
|
+
broadcastable to counts; returns (..., C + N_COUNT)."""
|
|
32
|
+
delta = (counts - n_used[..., None] * prior) / (n_used[..., None] + smooth)
|
|
33
|
+
cnt = torch.stack([torch.log1p(n_used) / 4, torch.log1p(n_freq) / 4, n_freq / n], -1)
|
|
34
|
+
return torch.cat([delta, cnt], -1)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def context_stats(x, y, cat, n_classes, smooth=1.0, perm=None, generator=None):
|
|
38
|
+
"""x: (B, n, m) context values (NaN = missing), y: (B, n) classes in 0..n_classes-1, cat: (B, m) bool.
|
|
39
|
+
perm: (B, n) the row order of the ordered statistics. Default: random per task from the global RNG, or,
|
|
40
|
+
with a generator, one order drawn from it for every task (the same whatever the batch).
|
|
41
|
+
|
|
42
|
+
Returns (feats, table): feats (B, n, m, n_classes + N_COUNT) float32, zero on numerical columns, and
|
|
43
|
+
the table query_stats needs; (None, None) when no column is categorical."""
|
|
44
|
+
B, n, m = x.shape
|
|
45
|
+
C = int(n_classes)
|
|
46
|
+
dev = x.device
|
|
47
|
+
mc = int(cat.sum(1).max()) if cat.numel() else 0
|
|
48
|
+
if mc == 0:
|
|
49
|
+
return None, None
|
|
50
|
+
# the categorical columns of each task first, in order; the tail of a task with fewer is padding
|
|
51
|
+
cols = torch.argsort((~cat).to(torch.int8), dim=1, stable=True)[:, :mc] # (B, mc)
|
|
52
|
+
valid = torch.gather(cat, 1, cols)
|
|
53
|
+
keys = _keys(torch.gather(x, 2, cols[:, None].expand(B, n, mc)))
|
|
54
|
+
if perm is None:
|
|
55
|
+
if generator is None:
|
|
56
|
+
perm = torch.argsort(torch.rand(B, n, device=dev), 1)
|
|
57
|
+
else:
|
|
58
|
+
perm = torch.argsort(torch.rand(n, generator=generator)).expand(B, n)
|
|
59
|
+
perm = perm.to(dev)
|
|
60
|
+
pe = perm[..., None].expand(B, n, mc)
|
|
61
|
+
kp = torch.gather(keys, 1, pe)
|
|
62
|
+
vs, order = torch.sort(kp, dim=1, stable=True) # stable: rows of a category keep the random order
|
|
63
|
+
rows = torch.gather(pe, 1, order) # original row of every sorted position
|
|
64
|
+
ys = torch.gather(y.long()[..., None].expand(B, n, mc), 1, rows)
|
|
65
|
+
oh = F.one_hot(ys, C).float() # (B, n, mc, C)
|
|
66
|
+
start = torch.ones_like(vs, dtype=torch.bool)
|
|
67
|
+
start[:, 1:] = vs[:, 1:] != vs[:, :-1]
|
|
68
|
+
end = torch.ones_like(start)
|
|
69
|
+
end[:, :-1] = start[:, 1:]
|
|
70
|
+
ar = torch.arange(n, device=dev)[None, :, None]
|
|
71
|
+
first = torch.where(start, ar, 0).cummax(1).values # first sorted position of the category
|
|
72
|
+
last = (n - 1 - torch.where(end.flip(1), ar, 0).cummax(1).values).flip(1) # last one
|
|
73
|
+
incl = oh.cumsum(1)
|
|
74
|
+
excl = incl - oh
|
|
75
|
+
del oh
|
|
76
|
+
base = torch.gather(excl, 1, first[..., None].expand_as(excl))
|
|
77
|
+
prefix = excl.sub_(base) # rows of the category before this one in the random order
|
|
78
|
+
# Query counts are constant within a category. Cache one entry per category rather than n
|
|
79
|
+
# repeated entries per column (hundreds of MB per estimator at 60k rows / 100 columns).
|
|
80
|
+
n_keys = int((start.sum(1) * valid).max())
|
|
81
|
+
category = start.long().cumsum(1) - 1
|
|
82
|
+
bi, ri, ci = torch.where(end & valid[:, None])
|
|
83
|
+
ki = category[bi, ri, ci]
|
|
84
|
+
table_keys = torch.full((B, mc, n_keys), math.inf, device=dev)
|
|
85
|
+
table_total = torch.zeros(B, mc, n_keys, C, device=dev)
|
|
86
|
+
table_keys[bi, ci, ki] = vs[bi, ri, ci]
|
|
87
|
+
table_total[bi, ci, ki] = incl[bi, ri, ci] - base[bi, ri, ci]
|
|
88
|
+
del incl, excl, base
|
|
89
|
+
prior = F.one_hot(y.long(), C).float().mean(1)[:, None, None] # (B, 1, 1, C)
|
|
90
|
+
n_used = (ar - first).float()
|
|
91
|
+
feats = _features(prefix, n_used, (last - first).float(), prior, n, smooth) * valid[:, None, :, None]
|
|
92
|
+
out = torch.zeros(B, n, mc, feats.shape[-1], device=dev)
|
|
93
|
+
out.scatter_(1, rows[..., None].expand_as(feats), feats)
|
|
94
|
+
full = torch.zeros(B, n, m, feats.shape[-1], device=dev)
|
|
95
|
+
full.scatter_(2, cols[:, None, :, None].expand_as(out), out) # cols is a permutation prefix: no repeats
|
|
96
|
+
table = dict(cols=cols, valid=valid, keys=table_keys,
|
|
97
|
+
total=table_total, prior=prior, n=n, smooth=smooth)
|
|
98
|
+
return full, table
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def query_stats(table, x):
|
|
102
|
+
"""Features (B, M, m, C + N_COUNT) of query rows x: (B, M, m) from the table of context_stats."""
|
|
103
|
+
if table is None:
|
|
104
|
+
return None
|
|
105
|
+
B, M, m = x.shape
|
|
106
|
+
cols, valid, keys, total = table["cols"], table["valid"], table["keys"], table["total"]
|
|
107
|
+
mc, n_keys, C = keys.shape[1], keys.shape[2], total.shape[-1]
|
|
108
|
+
q = _keys(torch.gather(x, 2, cols[:, None].expand(B, M, mc))).transpose(1, 2).contiguous() # (B, mc, M)
|
|
109
|
+
pos = torch.searchsorted(keys, q).clamp(max=n_keys - 1)
|
|
110
|
+
hit = torch.gather(keys, 2, pos) == q
|
|
111
|
+
counts = torch.gather(total, 2, pos[..., None].expand(B, mc, M, C)) * hit[..., None]
|
|
112
|
+
n_used = counts.sum(-1)
|
|
113
|
+
feats = _features(counts, n_used, n_used, table["prior"], table["n"], table["smooth"])
|
|
114
|
+
feats = (feats * valid[:, :, None, None]).transpose(1, 2) # (B, M, mc, F)
|
|
115
|
+
full = torch.zeros(B, M, m, feats.shape[-1], device=x.device)
|
|
116
|
+
full.scatter_(2, cols[:, None, :, None].expand_as(feats), feats)
|
|
117
|
+
return full
|
lightpfn/model/layers.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Building blocks shared by the three stages of the model.
|
|
2
|
+
|
|
3
|
+
Tensors are (batch, sequence, dim) unless a name says otherwise. Attention runs through
|
|
4
|
+
F.scaled_dot_product_attention, so the same code uses flash kernels on GPU and fused CPU kernels.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
import torch
|
|
10
|
+
import torch.nn.functional as F
|
|
11
|
+
from torch import nn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MLP(nn.Module):
|
|
15
|
+
"""Two-layer GELU feed-forward block; the output projection starts at zero so every residual
|
|
16
|
+
block starts as the identity (as in TabPFN-3)."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, dim, hidden):
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.fc1 = nn.Linear(dim, hidden, bias=False)
|
|
21
|
+
self.fc2 = nn.Linear(hidden, dim, bias=False)
|
|
22
|
+
nn.init.zeros_(self.fc2.weight)
|
|
23
|
+
|
|
24
|
+
def forward(self, x):
|
|
25
|
+
return self.fc2(F.gelu(self.fc1(x)))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SoftmaxScaling(nn.Module):
|
|
29
|
+
"""Scales attention queries as a function of the number of keys n (TabPFN-3 SoftmaxScalingMLP):
|
|
30
|
+
q * base(log n) * (1 + tanh(mod(q))). Keeps attention sharp when the context grows far beyond
|
|
31
|
+
the lengths seen in training. base starts at 1 so the layer starts as a no-op."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, n_heads, head_dim, hidden=64):
|
|
34
|
+
super().__init__()
|
|
35
|
+
self.n_heads, self.head_dim = n_heads, head_dim
|
|
36
|
+
self.base = nn.Sequential(nn.Linear(1, hidden), nn.GELU(), nn.Linear(hidden, n_heads * head_dim))
|
|
37
|
+
self.mod = nn.Sequential(nn.Linear(head_dim, hidden), nn.GELU(), nn.Linear(hidden, head_dim))
|
|
38
|
+
nn.init.zeros_(self.base[2].weight)
|
|
39
|
+
nn.init.ones_(self.base[2].bias)
|
|
40
|
+
nn.init.zeros_(self.mod[2].weight)
|
|
41
|
+
nn.init.zeros_(self.mod[2].bias)
|
|
42
|
+
|
|
43
|
+
def forward(self, q, n):
|
|
44
|
+
"""q: (B, H, L, D) queries, n: number of keys."""
|
|
45
|
+
logn = torch.full((1, 1), math.log(max(n, 2)), device=q.device, dtype=q.dtype)
|
|
46
|
+
base = self.base(logn).view(1, self.n_heads, 1, self.head_dim)
|
|
47
|
+
return q * base * (1 + torch.tanh(self.mod(q)))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# CUDA attention kernels put the batch on a grid dimension of at most 65535 blocks: a larger batch fails
|
|
51
|
+
# with "invalid argument" (the row stages of a 2-task group of 50k-row tables have 100k rows in the batch).
|
|
52
|
+
SDPA_MAX_BATCH = 32768
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sdpa(q, k, v, mask=None):
|
|
56
|
+
"""F.scaled_dot_product_attention in chunks of at most SDPA_MAX_BATCH along the batch dim (a size-1
|
|
57
|
+
batch dim of k, v or mask broadcasts)."""
|
|
58
|
+
B = q.shape[0]
|
|
59
|
+
if B <= SDPA_MAX_BATCH:
|
|
60
|
+
return F.scaled_dot_product_attention(q, k, v, attn_mask=mask)
|
|
61
|
+
out = []
|
|
62
|
+
for i in range(0, B, SDPA_MAX_BATCH):
|
|
63
|
+
part = [t if t is None or t.shape[0] == 1 else t[i:i + SDPA_MAX_BATCH] for t in (k, v, mask)]
|
|
64
|
+
out.append(F.scaled_dot_product_attention(q[i:i + SDPA_MAX_BATCH], *part[:2], attn_mask=part[2]))
|
|
65
|
+
return torch.cat(out)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def rope(x, pos, base=10000.0, interleaved=False):
|
|
69
|
+
"""Rotary position embedding (rotate-half form) on the last dim of x: (..., L, D) with
|
|
70
|
+
positions pos: (L,). interleaved=True rotates the pairs (2i, 2i + 1) instead of (i, i + D/2),
|
|
71
|
+
with one complex multiply: the same function for weights passed through interleave_rotary()."""
|
|
72
|
+
d = x.shape[-1]
|
|
73
|
+
inv_freq = base ** (-torch.arange(0, d, 2, device=x.device, dtype=torch.float32) / d)
|
|
74
|
+
ang = pos.to(torch.float32)[:, None] * inv_freq[None] # (L, D/2)
|
|
75
|
+
if interleaved and x.dtype in (torch.float32, torch.float64):
|
|
76
|
+
rot = torch.complex(ang.cos(), ang.sin()).to(torch.complex64 if x.dtype == torch.float32 else torch.complex128)
|
|
77
|
+
return torch.view_as_real(torch.view_as_complex(x.unflatten(-1, (d // 2, 2))) * rot).flatten(-2)
|
|
78
|
+
cos, sin = ang.cos().to(x.dtype), ang.sin().to(x.dtype)
|
|
79
|
+
if interleaved: # reduced precision (autocast): the same arithmetic as below on adjacent pairs
|
|
80
|
+
x1, x2 = x[..., 0::2], x[..., 1::2]
|
|
81
|
+
return torch.stack([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1).flatten(-2)
|
|
82
|
+
x1, x2 = x[..., : d // 2], x[..., d // 2 :]
|
|
83
|
+
return torch.cat([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Attention(nn.Module):
|
|
87
|
+
"""Multi-head attention with separate query and key/value inputs.
|
|
88
|
+
|
|
89
|
+
kv_heads_query < n_heads lets a set of queries use only the first key/value heads (multi-query
|
|
90
|
+
attention for test rows, as in TabPFN-3.5): keys/values of the other heads never need to be
|
|
91
|
+
cached for prediction."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, dim, n_heads, scaling=False):
|
|
94
|
+
super().__init__()
|
|
95
|
+
assert dim % n_heads == 0
|
|
96
|
+
self.n_heads, self.head_dim = n_heads, dim // n_heads
|
|
97
|
+
self.q = nn.Linear(dim, dim, bias=False)
|
|
98
|
+
self.kv = nn.Linear(dim, 2 * dim, bias=False)
|
|
99
|
+
self.out = nn.Linear(dim, dim, bias=False)
|
|
100
|
+
nn.init.zeros_(self.out.weight)
|
|
101
|
+
self.scaling = SoftmaxScaling(n_heads, self.head_dim) if scaling else None
|
|
102
|
+
|
|
103
|
+
def split(self, x):
|
|
104
|
+
B, L, _ = x.shape
|
|
105
|
+
return x.view(B, L, self.n_heads, self.head_dim).transpose(1, 2) # (B, H, L, D)
|
|
106
|
+
|
|
107
|
+
def keys_values(self, x):
|
|
108
|
+
k, v = self.kv(x).chunk(2, dim=-1)
|
|
109
|
+
return self.split(k), self.split(v)
|
|
110
|
+
|
|
111
|
+
def attend(self, x, k, v, mask=None, q_rope=None, kv_heads=None):
|
|
112
|
+
"""x: (B, Lq, dim) queries; k, v: (B, Hkv, Lk, D). kv_heads=h: all query heads use the
|
|
113
|
+
first h key/value heads."""
|
|
114
|
+
q = self.split(self.q(x))
|
|
115
|
+
if q_rope is not None:
|
|
116
|
+
q = q_rope(q)
|
|
117
|
+
if self.scaling is not None:
|
|
118
|
+
q = self.scaling(q, k.shape[2])
|
|
119
|
+
if kv_heads is not None:
|
|
120
|
+
k = k[:, :kv_heads].repeat_interleave(self.n_heads // kv_heads, dim=1)
|
|
121
|
+
v = v[:, :kv_heads].repeat_interleave(self.n_heads // kv_heads, dim=1)
|
|
122
|
+
o = sdpa(q, k, v, mask)
|
|
123
|
+
B, H, L, D = o.shape
|
|
124
|
+
return self.out(o.transpose(1, 2).reshape(B, L, H * D))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Block(nn.Module):
|
|
128
|
+
"""Pre-norm residual block: attention of x on a key/value sequence, then an MLP."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, dim, n_heads, ff_factor=2, scaling=False, ff_hidden=None):
|
|
131
|
+
super().__init__()
|
|
132
|
+
self.norm_q = nn.RMSNorm(dim)
|
|
133
|
+
self.norm_kv = nn.RMSNorm(dim)
|
|
134
|
+
self.norm_ff = nn.RMSNorm(dim)
|
|
135
|
+
self.attn = Attention(dim, n_heads, scaling)
|
|
136
|
+
self.mlp = MLP(dim, dim * ff_factor if ff_hidden is None else ff_hidden)
|
|
137
|
+
|
|
138
|
+
def keys_values(self, kv_input):
|
|
139
|
+
return self.attn.keys_values(self.norm_kv(kv_input))
|
|
140
|
+
|
|
141
|
+
def forward(self, x, k, v, mask=None, q_rope=None, kv_heads=None):
|
|
142
|
+
x = x + self.attn.attend(self.norm_q(x), k, v, mask, q_rope, kv_heads)
|
|
143
|
+
return x + self.mlp(self.norm_ff(x))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class FoldedRMSNorm(nn.Module):
|
|
147
|
+
"""RMSNorm whose weight was folded into the linear layer that reads it (inference only): one
|
|
148
|
+
reduction and one multiply instead of the unfused CPU kernels. Same eps as nn.RMSNorm (eps=None:
|
|
149
|
+
that of the accumulation dtype, float32 for reduced precision), reduction in at least float32."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, dim, eps=None):
|
|
152
|
+
super().__init__()
|
|
153
|
+
self.dim, self.eps = dim, eps
|
|
154
|
+
|
|
155
|
+
def forward(self, x):
|
|
156
|
+
acc = torch.promote_types(x.dtype, torch.float32)
|
|
157
|
+
eps = torch.finfo(acc).eps if self.eps is None else self.eps
|
|
158
|
+
ms = torch.linalg.vector_norm(x, dim=-1, keepdim=True, dtype=acc).square_().div_(self.dim)
|
|
159
|
+
return x * ms.add_(eps).rsqrt_().to(x.dtype)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@torch.no_grad()
|
|
163
|
+
def fold_norm(norm, *linears):
|
|
164
|
+
"""Moves the weight of an RMSNorm into the linear layers fed by it; returns the weightless norm."""
|
|
165
|
+
for lin in linears:
|
|
166
|
+
lin.weight.mul_(norm.weight)
|
|
167
|
+
return FoldedRMSNorm(norm.weight.numel(), norm.eps)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@torch.no_grad()
|
|
171
|
+
def interleave_rotary(block):
|
|
172
|
+
"""Reorders the query and key dims of each head of a block from (i, i + D/2) pairs to adjacent
|
|
173
|
+
(2i, 2i + 1) pairs: q.k is unchanged (same permutation on both), and rope(..., interleaved=True)
|
|
174
|
+
rotates the same pairs."""
|
|
175
|
+
a = block.attn
|
|
176
|
+
H, D = a.n_heads, a.head_dim
|
|
177
|
+
pairs = torch.stack([torch.arange(D // 2), torch.arange(D // 2) + D // 2], -1).flatten()
|
|
178
|
+
rows = (torch.arange(H)[:, None] * D + pairs).flatten().to(a.q.weight.device)
|
|
179
|
+
a.q.weight.copy_(a.q.weight[rows])
|
|
180
|
+
a.kv.weight[: H * D].copy_(a.kv.weight[rows])
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class OrthogonalEmbedding(nn.Embedding):
|
|
184
|
+
"""Label embedding with orthonormal initial rows (TabPFN-3 TrainableOrthogonalEmbedding)."""
|
|
185
|
+
|
|
186
|
+
def __init__(self, n, dim):
|
|
187
|
+
super().__init__(n, dim)
|
|
188
|
+
with torch.no_grad():
|
|
189
|
+
q, _ = torch.linalg.qr(torch.randn(dim, min(n, dim)))
|
|
190
|
+
self.weight[: q.shape[1]] = q.T
|