Note
Go to the end to download the full example code.
Custom Entity Metric#
Learn how to implement a custom entity-level distance metric by subclassing
EntityMetric.
An entity metric computes a scalar distance between two individual entities (atomic observations in a sequence). It is the basic building block used by most sequence-level metrics.
Minimal contract
Declare a
SETTINGS_CLASSdataclass (usetanat_utils.settings_dataclass).Implement
validate_entity(ent_a, ent_b): raiseTypeError/KeyErrorwhen the entities are incompatible with the metric.Implement
_compute(ent_a, ent_b): return a non-negativefloat.
The public __call__ in the base class invokes validate_entity then
_compute automatically.
Setup#
import polars as pl
from tanat_utils import settings_dataclass as dataclass
from tanat import build_events
from tanat.dataset import simulate_events
from tanat.metric.entity.base import EntityMetric
Data#
A small event pool with a score numeric feature and a status
categorical feature.
raw = simulate_events(n_ids=20, features=["score", "status"], seed=0)
pool = build_events(temporal_data=raw, id_column="id", time_column="time")
pool.cast_features({"score": pl.Float32})
┌─ Event SequenceStore
│
│ Step 1/4: Sorting & preparing data
│
│ Step 2/4: Building sequence index
│
│ Step 3/4: Writing entity & time index features
│
│ Step 4/4: Computing & writing metadata
│
└─ Done (20 sequences · 138 entities · 0.00s)
print(pool)
┌────────────────────────────────────────────────┐
│ EventSequencePool Summary │
└────────────────────────────────────────────────┘
Overview
─────────────────────────
Sequences 20
Store /home/runner/.tanat/_quick_event_28c920a6
id_column id
Time Index
─────────────────────────
Type Datetime(time_unit='us', time_zone=None) [2000-01-03 17:54:05.937674 → 2024-11-15 14:02:43.302235]
Columns ['time']
t0 position=0, anchor=None
Entity Features (2)
─────────────────────────
• score Numerical [1.0 → 100.0]
• status String [len 1 → 1]
Define a custom entity metric#
We implement a normalised absolute difference on a numeric feature.
dist(a, b) = |a.score - b.score| / scale
@dataclass
class ScoreDiffSettings:
"""Settings for :class:`ScoreDiffEntityMetric`.
Args:
entity_feature: Numeric feature to compare.
scale: Normalisation constant (``1.0`` means raw absolute diff).
"""
entity_feature: str = "score"
scale: float = 1.0
class ScoreDiffEntityMetric(EntityMetric, register_name="score_diff"):
"""Normalised absolute difference on a numeric entity feature."""
SETTINGS_CLASS = ScoreDiffSettings
def __init__(self, entity_feature: str = "score", scale: float = 1.0) -> None:
super().__init__(
settings=ScoreDiffSettings(entity_feature=entity_feature, scale=scale)
)
# ------------------------------------------------------------------
# Required implementations
# ------------------------------------------------------------------
def validate_entity(self, ent_a, ent_b=None) -> None:
"""Check that both entities expose the expected numeric feature."""
self._validate_entity_instance(ent_a, ent_b)
feat = self.settings.entity_feature
for ent in (e for e in (ent_a, ent_b) if e is not None):
if feat not in ent.data():
raise KeyError(
f"Feature {feat!r} not found in entity. "
f"Available: {list(ent.data().keys())}"
)
def _compute(self, ent_a, ent_b) -> float:
"""Return normalised absolute difference of the configured feature."""
feat = self.settings.entity_feature
val_a = float(ent_a[feat])
val_b = float(ent_b[feat])
return abs(val_a - val_b) / self.settings.scale
Instantiate and inspect#
metric = ScoreDiffEntityMetric(entity_feature="score", scale=100.0)
print(metric)
ScoreDiffEntityMetric(settings=ScoreDiffSettings(entity_feature='score', scale=100.0))
Compute a distance between two entities#
ids = pool.unique_ids
ent_a = pool[ids[0]][0]
ent_b = pool[ids[1]][0]
print(f"score A : {ent_a['score']}")
print(f"score B : {ent_b['score']}")
print(f"distance: {metric(ent_a, ent_b):.4f}")
score A : 28.0
score B : 73.0
distance: 0.4500
Compute distances over several pairs#
print("Sample pairwise distances")
print("-" * 40)
for i in range(5):
ea = pool[ids[i]][0]
eb = pool[ids[i + 1]][0]
d = metric(ea, eb)
print(f" {ea['score']:6.1f} vs {eb['score']:6.1f} -> {d:.4f}")
Sample pairwise distances
----------------------------------------
28.0 vs 73.0 -> 0.4500
73.0 vs 30.0 -> 0.4300
30.0 vs 1.0 -> 0.2900
1.0 vs 62.0 -> 0.6100
62.0 vs 81.0 -> 0.1900
Use the custom metric inside a sequence metric#
Any EntityMetric can be passed as the
entity_metric argument to sequence-level metrics such as
LinearPairwiseSequenceMetric.
from tanat.metric.sequence import LinearPairwiseSequenceMetric
lp = LinearPairwiseSequenceMetric(entity_metric=metric)
print(lp)
LinearPairwiseSequenceMetric(settings=LinearPairwiseSettings(entity_metric=ScoreDiffEntityMetric(settings=ScoreDiffSettings(entity_feature='score', scale=100.0)), agg_fun='mean', padding_penalty=None))
seq_a = pool[ids[0]]
seq_b = pool[ids[1]]
dist = lp(seq_a, seq_b)
print(f"LinearPairwise distance: {dist:.4f}")
LinearPairwise distance: 0.3025
dm = lp.compute_matrix(pool)
dm.to_frame().head()
# .. note::
#
# Such an custom metric is not optimized for large datasets. TanaT has mechanisms
# based on the Numba framework.
# For implementing an
# computationaly efficient metric, we invite the developers to contact the core
# developer team or to look at the code of the implemented entity metrics (especially
# hamming).
┌─ LinearPairwiseSequenceMetric
│
│ Pairs: 0%| | 0/400 [00:00<?, ?it/s]
│ Pairs: 1%| | 3/400 [00:00<00:15, 25.82it/s]
│ Pairs: 2%|▏ | 9/400 [00:00<00:09, 41.00it/s]
│ Pairs: 4%|▎ | 14/400 [00:00<00:13, 29.66it/s]
│ Pairs: 4%|▍ | 18/400 [00:00<00:13, 28.29it/s]
│ Pairs: 6%|▌ | 22/400 [00:00<00:13, 27.41it/s]
│ Pairs: 7%|▋ | 27/400 [00:00<00:11, 31.93it/s]
│ Pairs: 8%|▊ | 31/400 [00:00<00:11, 32.94it/s]
│ Pairs: 9%|▉ | 35/400 [00:01<00:11, 31.03it/s]
│ Pairs: 10%|▉ | 39/400 [00:01<00:12, 29.87it/s]
│ Pairs: 11%|█ | 43/400 [00:01<00:12, 29.48it/s]
│ Pairs: 12%|█▏ | 48/400 [00:01<00:10, 34.21it/s]
│ Pairs: 13%|█▎ | 52/400 [00:01<00:10, 33.87it/s]
│ Pairs: 14%|█▍ | 56/400 [00:01<00:10, 32.45it/s]
│ Pairs: 15%|█▌ | 60/400 [00:01<00:10, 31.54it/s]
│ Pairs: 16%|█▋ | 65/400 [00:02<00:09, 34.15it/s]
│ Pairs: 18%|█▊ | 71/400 [00:02<00:08, 39.10it/s]
│ Pairs: 19%|█▉ | 76/400 [00:02<00:08, 39.47it/s]
│ Pairs: 20%|██ | 81/400 [00:02<00:08, 39.73it/s]
│ Pairs: 22%|██▏ | 86/400 [00:02<00:07, 40.87it/s]
│ Pairs: 23%|██▎ | 91/400 [00:02<00:07, 43.25it/s]
│ Pairs: 24%|██▍ | 96/400 [00:02<00:07, 42.32it/s]
│ Pairs: 25%|██▌ | 101/400 [00:02<00:06, 42.80it/s]
│ Pairs: 27%|██▋ | 108/400 [00:02<00:05, 48.93it/s]
│ Pairs: 29%|██▉ | 115/400 [00:03<00:05, 53.27it/s]
│ Pairs: 30%|███ | 122/400 [00:03<00:04, 56.33it/s]
│ Pairs: 32%|███▏ | 129/400 [00:03<00:04, 58.66it/s]
│ Pairs: 34%|███▍ | 136/400 [00:03<00:04, 60.39it/s]
│ Pairs: 36%|███▌ | 143/400 [00:03<00:04, 61.68it/s]
│ Pairs: 38%|███▊ | 150/400 [00:03<00:03, 62.61it/s]
│ Pairs: 39%|███▉ | 157/400 [00:03<00:03, 63.31it/s]
│ Pairs: 41%|████ | 164/400 [00:03<00:03, 60.62it/s]
│ Pairs: 43%|████▎ | 171/400 [00:03<00:03, 57.80it/s]
│ Pairs: 44%|████▍ | 177/400 [00:04<00:04, 55.22it/s]
│ Pairs: 46%|████▌ | 183/400 [00:04<00:04, 47.11it/s]
│ Pairs: 47%|████▋ | 189/400 [00:04<00:04, 48.23it/s]
│ Pairs: 48%|████▊ | 194/400 [00:04<00:05, 39.52it/s]
│ Pairs: 50%|████▉ | 199/400 [00:04<00:05, 33.57it/s]
│ Pairs: 51%|█████ | 203/400 [00:04<00:06, 31.34it/s]
│ Pairs: 52%|█████▏ | 209/400 [00:05<00:05, 36.10it/s]
│ Pairs: 53%|█████▎ | 213/400 [00:05<00:05, 33.15it/s]
│ Pairs: 54%|█████▍ | 217/400 [00:05<00:05, 31.27it/s]
│ Pairs: 55%|█████▌ | 221/400 [00:05<00:06, 29.74it/s]
│ Pairs: 56%|█████▋ | 225/400 [00:05<00:05, 30.55it/s]
│ Pairs: 57%|█████▊ | 230/400 [00:05<00:04, 34.20it/s]
│ Pairs: 58%|█████▊ | 234/400 [00:05<00:05, 31.44it/s]
│ Pairs: 60%|█████▉ | 238/400 [00:06<00:05, 29.39it/s]
│ Pairs: 60%|██████ | 242/400 [00:06<00:05, 28.61it/s]
│ Pairs: 62%|██████▏ | 247/400 [00:06<00:04, 32.45it/s]
│ Pairs: 63%|██████▎ | 251/400 [00:06<00:04, 33.93it/s]
│ Pairs: 64%|██████▍ | 255/400 [00:06<00:04, 32.57it/s]
│ Pairs: 65%|██████▍ | 259/400 [00:06<00:04, 31.62it/s]
│ Pairs: 66%|██████▌ | 263/400 [00:06<00:04, 30.97it/s]
│ Pairs: 67%|██████▋ | 269/400 [00:06<00:03, 36.71it/s]
│ Pairs: 68%|██████▊ | 273/400 [00:07<00:03, 34.44it/s]
│ Pairs: 69%|██████▉ | 277/400 [00:07<00:03, 32.80it/s]
│ Pairs: 70%|███████ | 281/400 [00:07<00:03, 31.15it/s]
│ Pairs: 71%|███████▏ | 285/400 [00:07<00:03, 31.69it/s]
│ Pairs: 72%|███████▎ | 290/400 [00:07<00:03, 35.40it/s]
│ Pairs: 74%|███████▎ | 294/400 [00:07<00:03, 32.20it/s]
│ Pairs: 74%|███████▍ | 298/400 [00:07<00:03, 29.84it/s]
│ Pairs: 76%|███████▌ | 302/400 [00:08<00:03, 28.31it/s]
│ Pairs: 77%|███████▋ | 307/400 [00:08<00:02, 32.15it/s]
│ Pairs: 78%|███████▊ | 311/400 [00:08<00:02, 33.00it/s]
│ Pairs: 79%|███████▉ | 315/400 [00:08<00:02, 31.24it/s]
│ Pairs: 80%|███████▉ | 319/400 [00:08<00:02, 30.05it/s]
│ Pairs: 81%|████████ | 323/400 [00:08<00:02, 28.99it/s]
│ Pairs: 82%|████████▏ | 329/400 [00:08<00:02, 35.03it/s]
│ Pairs: 83%|████████▎ | 333/400 [00:09<00:02, 32.42it/s]
│ Pairs: 84%|████████▍ | 337/400 [00:09<00:02, 30.65it/s]
│ Pairs: 85%|████████▌ | 341/400 [00:09<00:01, 30.06it/s]
│ Pairs: 86%|████████▋ | 345/400 [00:09<00:01, 31.19it/s]
│ Pairs: 88%|████████▊ | 351/400 [00:09<00:01, 35.26it/s]
│ Pairs: 89%|████████▉ | 355/400 [00:09<00:01, 33.46it/s]
│ Pairs: 90%|████████▉ | 359/400 [00:09<00:01, 32.32it/s]
│ Pairs: 91%|█████████ | 363/400 [00:09<00:01, 31.57it/s]
│ Pairs: 92%|█████████▏| 369/400 [00:10<00:00, 37.12it/s]
│ Pairs: 93%|█████████▎| 373/400 [00:10<00:00, 34.82it/s]
│ Pairs: 94%|█████████▍| 377/400 [00:10<00:00, 33.34it/s]
│ Pairs: 95%|█████████▌| 381/400 [00:10<00:00, 31.65it/s]
│ Pairs: 96%|█████████▋| 385/400 [00:10<00:00, 32.08it/s]
│ Pairs: 98%|█████████▊| 390/400 [00:10<00:00, 34.27it/s]
│ Pairs: 98%|█████████▊| 394/400 [00:10<00:00, 31.48it/s]
│ Pairs: 100%|█████████▉| 398/400 [00:11<00:00, 29.37it/s]
│ Pairs: 100%|██████████| 400/400 [00:11<00:00, 35.96it/s]
│
└─ Done (20 sequences · 11.12s)
Total running time of the script: (0 minutes 11.240 seconds)