Skip to content

Commit 3d4d5a8

Browse files
WhiteSwan1claude
andauthored
[feat] SID: add SidRqkmeans model (FAISS-trained residual K-Means) (#539)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 93ad0e0 commit 3d4d5a8

19 files changed

Lines changed: 1914 additions & 9 deletions

tzrec/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,12 @@ def run_eval(step: int, epoch: int) -> None:
515515
if lr.by_epoch:
516516
lr.step()
517517

518+
# One-shot end-of-loop hook (default no-op; e.g. SidRqkmeans fits its FAISS
519+
# codebook here). SID models run with periodic checkpointing disabled
520+
# (save_checkpoints_steps/epochs = 0), so the tail final=True save below is
521+
# the only checkpoint and persists whatever on_train_end produced.
522+
_model.on_train_end()
523+
518524
_log_train(
519525
i_step,
520526
losses,

tzrec/metrics/relative_l1.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Copyright (c) 2026, Alibaba Group;
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
# Unless required by applicable law or agreed to in writing, software
7+
# distributed under the License is distributed on an "AS IS" BASIS,
8+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
# See the License for the specific language governing permissions and
10+
# limitations under the License.
11+
12+
import torch
13+
from torchmetrics import Metric
14+
15+
16+
class RelativeL1(Metric):
17+
"""Mean symmetric relative-L1 error ``|t - p| / (max(|t|, |p|) + eps)``.
18+
19+
A bounded reconstruction-error metric (0 = exact, → 1 = unrelated). It is a
20+
verbatim port of OpenOneRec's residual-K-Means ``calc_loss`` and is
21+
deliberately **not** ``torchmetrics.MeanAbsolutePercentageError``, which uses
22+
the asymmetric ``|t - p| / |t|`` denominator. Aggregation is element-wise
23+
(count-weighted), so the reported value is the mean over all elements seen.
24+
"""
25+
26+
higher_is_better = False
27+
is_differentiable = True
28+
29+
def __init__(self, epsilon: float = 1e-4, **kwargs) -> None:
30+
super().__init__(**kwargs)
31+
self.epsilon = epsilon
32+
# float64 sum / long count: float32 loses integer precision past 2**24
33+
# (~32K rows of a 512-dim embedding) under element-wise aggregation.
34+
self.add_state(
35+
"sum_rel",
36+
default=torch.tensor(0.0, dtype=torch.float64),
37+
dist_reduce_fx="sum",
38+
)
39+
self.add_state(
40+
"count", default=torch.tensor(0, dtype=torch.long), dist_reduce_fx="sum"
41+
)
42+
43+
def update(self, preds: torch.Tensor, target: torch.Tensor) -> None:
44+
"""Accumulate the relative-L1 error between ``preds`` and ``target``.
45+
46+
Args:
47+
preds (Tensor): reconstruction, shape (B, D).
48+
target (Tensor): ground-truth embedding, shape (B, D).
49+
"""
50+
rel = torch.abs(target - preds) / (
51+
torch.maximum(torch.abs(target), torch.abs(preds)) + self.epsilon
52+
)
53+
self.sum_rel += rel.sum().double()
54+
self.count += rel.numel()
55+
56+
def compute(self) -> torch.Tensor:
57+
"""Mean relative-L1 over all elements (NaN before any update)."""
58+
return self.sum_rel / self.count

tzrec/metrics/relative_l1_test.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Copyright (c) 2026, Alibaba Group;
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
# Unless required by applicable law or agreed to in writing, software
7+
# distributed under the License is distributed on an "AS IS" BASIS,
8+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
# See the License for the specific language governing permissions and
10+
# limitations under the License.
11+
12+
import unittest
13+
14+
import torch
15+
16+
from tzrec.metrics.relative_l1 import RelativeL1
17+
18+
19+
class RelativeL1Test(unittest.TestCase):
20+
def test_zero_on_identity(self) -> None:
21+
metric = RelativeL1()
22+
x = torch.randn(8, 4)
23+
metric.update(x, x.clone())
24+
self.assertAlmostEqual(metric.compute().item(), 0.0, places=6)
25+
26+
def test_matches_formula(self) -> None:
27+
metric = RelativeL1(epsilon=1e-4)
28+
p = torch.tensor([[1.0, 0.0]])
29+
t = torch.tensor([[0.0, 2.0]])
30+
# |t-p|/(max(|t|,|p|)+eps): [1/(1+eps), 2/(2+eps)], mean of the two.
31+
expected = (1.0 / (1.0 + 1e-4) + 2.0 / (2.0 + 1e-4)) / 2
32+
metric.update(p, t)
33+
self.assertAlmostEqual(metric.compute().item(), expected, places=5)
34+
35+
def test_count_weighted_across_updates(self) -> None:
36+
"""Aggregation is element-wise, not a mean of per-batch means."""
37+
metric = RelativeL1()
38+
metric.update(torch.zeros(1, 4), torch.ones(1, 4)) # 4 elems, rel ~1
39+
metric.update(torch.ones(3, 4), torch.ones(3, 4)) # 12 elems, rel 0
40+
# Element-weighted: 4 nonzero over 16 elems -> ~0.25, NOT (1+0)/2 = 0.5.
41+
per = 1.0 / (1.0 + 1e-4) # rel of a 0-vs-1 element (with epsilon)
42+
self.assertAlmostEqual(metric.compute().item(), 4 * per / 16, places=6)
43+
44+
def test_nan_before_update(self) -> None:
45+
self.assertTrue(torch.isnan(RelativeL1().compute()))
46+
47+
48+
if __name__ == "__main__":
49+
unittest.main()

tzrec/models/model.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,15 @@ def compute_train_metric(self) -> Dict[str, torch.Tensor]:
150150
metric_results[metric_name] = metric.compute()
151151
return metric_results
152152

153+
def on_train_end(self) -> None:
154+
"""Hook fired once after the train_eval loop exits.
155+
156+
Default no-op; override for one-shot end-of-loop work (e.g.
157+
:class:`SidRqkmeans` fits its FAISS codebook here). The tail
158+
``final=True`` checkpoint persists whatever it produced.
159+
"""
160+
return
161+
153162
def sparse_parameters(
154163
self,
155164
) -> Tuple[Iterable[nn.Parameter], Iterable[nn.Parameter]]:

tzrec/models/sid_model.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from tzrec.datasets.utils import BASE_DATA_GROUP, Batch
2020
from tzrec.features.feature import BaseFeature
21+
from tzrec.metrics.relative_l1 import RelativeL1
2122
from tzrec.metrics.unique_ratio import UniqueRatio
2223
from tzrec.models.model import BaseModel
2324
from tzrec.protos.model_pb2 import ModelConfig
@@ -40,9 +41,9 @@ class BaseSidModel(BaseModel):
4041
4142
Subclasses build their quantizer in ``__init__`` (after calling
4243
``super().__init__``) and implement :meth:`predict` and :meth:`loss`.
43-
They extend :meth:`init_metric` (via ``super()``) and implement
44-
:meth:`update_metric` to populate the registered metrics
45-
(:meth:`update_train_metric` defaults to a no-op).
44+
:meth:`predict` exposes the reconstruction under ``predictions["x_hat"]``
45+
(only when meaningful) so the shared :meth:`update_metric` can score it.
46+
(:meth:`update_train_metric` defaults to a no-op.)
4647
4748
Args:
4849
model_config (ModelConfig): an instance of ModelConfig.
@@ -69,8 +70,17 @@ def __init__(
6970
self._input_dim = cfg.input_dim
7071
self._normalize_residuals = cfg.normalize_residuals
7172

72-
assert cfg.codebook, "codebook must be set, e.g. [256, 256, 256]"
73+
if not cfg.codebook:
74+
raise ValueError("codebook must be set, e.g. [256, 256, 256]")
7375
self._n_embed_list = list(cfg.codebook)
76+
# Fail fast: a zero codebook entry / input_dim==0 only errors opaquely
77+
# deep inside faiss, after the whole training pass.
78+
if any(k < 1 for k in self._n_embed_list):
79+
raise ValueError(
80+
f"every codebook entry must be >= 1, got {self._n_embed_list}"
81+
)
82+
if self._input_dim < 1:
83+
raise ValueError(f"input_dim must be >= 1, got {self._input_dim}")
7484
self._n_layers = len(self._n_embed_list)
7585

7686
def _extract_feature(
@@ -99,14 +109,48 @@ def init_loss(self) -> None:
99109
def init_metric(self) -> None:
100110
"""Initialize the eval metrics shared by all SID models.
101111
102-
``mse``: reconstruction error (input vs. quantized / decoded).
103-
``unique_sid_ratio``: mean per-batch unique-SID ratio (distinct rows /
104-
batch size; a batch-size-sensitive diversity proxy, not global
105-
coverage). Subclasses call ``super().init_metric()`` then add extras.
112+
- ``mse``: reconstruction error (input vs. quantized / decoded).
113+
- ``rel_loss``: symmetric relative-L1 reconstruction error
114+
(:class:`~tzrec.metrics.relative_l1.RelativeL1`); meaningful only with
115+
``normalize_residuals=False`` (else the reconstruction and the input
116+
live on different scales).
117+
- ``unique_sid_ratio``: mean per-batch unique-SID ratio (distinct rows /
118+
batch size; a batch-size-sensitive diversity proxy, not global
119+
coverage).
120+
121+
Subclasses that add extras call ``super().init_metric()`` first.
106122
"""
107123
self._metric_modules["mse"] = torchmetrics.MeanSquaredError()
124+
self._metric_modules["rel_loss"] = RelativeL1()
108125
self._metric_modules["unique_sid_ratio"] = UniqueRatio()
109126

127+
def update_metric(
128+
self,
129+
predictions: Dict[str, torch.Tensor],
130+
batch: Batch,
131+
losses: Optional[Dict[str, torch.Tensor]] = None,
132+
) -> None:
133+
"""Update eval metrics from the reconstruction + the re-extracted input.
134+
135+
``predictions["x_hat"]`` is the model's reconstruction of the input
136+
embedding (the centroid sum for RQ-KMeans, the decoder output for
137+
RQ-VAE). Subclasses expose it only when it is meaningful, so a
138+
not-yet-fitted model omits it and this logs nothing. The target
139+
embedding is re-extracted from ``batch`` (it is an input, not an output).
140+
141+
Args:
142+
predictions (dict): a dict of predicted result.
143+
batch (Batch): input batch data.
144+
losses (dict, optional): a dict of loss.
145+
"""
146+
if "x_hat" not in predictions:
147+
return
148+
recon = predictions["x_hat"]
149+
embedding = self._extract_feature(batch)
150+
self._metric_modules["mse"].update(recon, embedding)
151+
self._metric_modules["rel_loss"].update(recon, embedding)
152+
self._metric_modules["unique_sid_ratio"].update(predictions["codes"])
153+
110154
def update_train_metric(
111155
self,
112156
predictions: Dict[str, torch.Tensor],

0 commit comments

Comments
 (0)