Extending fastabx¶
The pipeline is meant to be taken apart, each of its pieces can be replaced without touching the others. This page goes through the extension points, from the most to the least common.
Pooling: one vector per token¶
By default a token is a sequence of frames, and comparing two tokens requires an alignment.
pool_dataset collapses each token into a single vector instead:
from fastabx import Dataset, Score, Task, pool_dataset
dataset = Dataset.from_item(item, features, 50)
pooled = pool_dataset(dataset, "mean") # or "hamming"
task = Task(pooled, on="#phone", by=["speaker"])
print(Score(task, "angular").collapse(levels=["speaker"]))
"mean" averages the frames; "hamming" averages them under a Hamming window, giving less weight to the
boundary frames, which are the ones most contaminated by the neighbouring units. The result is a
PooledDataset, usable anywhere a Dataset is. Since every sequence now has a single frame,
the alignment is bypassed entirely, see Performance and memory.
Pool before scoring, as above. An "angular" Score normalizes its dataset in place, and pooling
one that has already been normalized is refused with a PoolingNormalizedError.
Custom alignments¶
A Distance compares individual frames and produces an (n1, n2, s1, s2) cost tensor. An
Alignment reduces it to one distance per pair of sequences. Dynamic time warping is the
only one shipped, but it is just a dynamic program over the lattice, and so is the edit distance for example:
from torch import Tensor
from fastabx import Score
def edit(cost: Tensor, sx: Tensor, sy: Tensor, *, symmetric: bool) -> Tensor:
"""Reduce a (n1, n2, s1, s2) lattice to the (n1, n2) distances between sequences."""
... # your dynamic program
score = Score(task, "identical", alignment=edit)
Two rules an implementation has to respect:
Normalize by the length of the alignment path. The ABX decision compares a X-to-A distance against a X-to-B distance; an unnormalized distance would systematically favour the shorter pair.
Read only the
(sx[i], sy[j])sub-block of each pair. Everything beyond those lengths is padding.
Hand-built triplets¶
When the triplets you want cannot be expressed as ON, BY and ACROSS conditions, build the cells yourself and
hand them to Task.from_cells. The DataFrame needs five columns: index_a, index_b and
index_x, each a list of row indices into Dataset.labels, plus a header and a description
string used when displaying the cell.
import polars as pl
from fastabx import Score, Task
cells = pl.DataFrame(
{
"header": ["a-b"],
"description": ["ON(phone_ax = a, phone_b = b)"],
"index_a": [[0, 2, 4]],
"index_b": [[1, 3, 5]],
"index_x": [[0, 2, 4]],
}
)
task = Task.from_cells(dataset, cells, is_symmetric=True)
print(Score(task, "euclidean").collapse(weighted=True))
is_symmetric says whether A and X are the same set. When it is True, index_a and index_x must
be equal row by row: scoring drops the diagonal of the distance matrix to avoid comparing a token with itself,
and that only makes sense if the two lists are the same, in the same order. Everything is checked up front,
and a violation raises PrecomputedCellsError.
One limitation: such a task has no condition columns, so Score.collapse needs weighted=True
rather than levels.
Constraints on triplets¶
Conditions operate at the level of cells. To filter inside a cell — excluding triplets where A and X come
from the same speaker, for instance — pass Constraints to the Score. They are polars
expressions over the labels of the three members of a triplet, suffixed with _a, _b and _x.
With constraints covers this in full.
Scoring a single cell¶
abx_on_cell is the primitive underneath everything else: it takes one Cell and returns its
ABX error rate. Useful to build your own loop over cells.
from fastabx import Task, abx_on_cell
task = Task(dataset, on="#phone", by=["speaker"])
print(abx_on_cell(task[0], "euclidean"))
Unlike Score, it does not normalize anything: with the default "angular" distance the
features must already be L2-normalized, via Dataset.normalize_, or the result is silently wrong.
Custom accessors¶
Dataset.accessor is typed as the Accessor protocol. The implementation that ships with fastabx,
InMemoryAccessor, holds every feature in one tensor; anything satisfying the protocol can take its
place, for example a memory-mapped store, a lazy reader, or a decoder that reconstructs features on demand:
import torch
from fastabx import Batch, Dataset
class MyAccessor:
device: torch.device
is_normalized: bool
def __len__(self) -> int: ...
def __getitem__(self, i: int) -> torch.Tensor: ...
def __iter__(self): ...
def lengths(self, indices: list[int]): ...
def batched(self, indices) -> Batch: ...
def normalize_(self) -> None: ...
dataset = Dataset(labels=labels, accessor=MyAccessor(...))
The scoring engine only ever reads through lengths and batched, so those two are the ones that have to
be fast; batched is where a lazy implementation would do its I/O, gathering many indices at once. Indices
are the row numbers of Dataset.labels: item i of the accessor describes row i.
Custom distances¶
A Distance is any callable taking two batches of representations and returning their pairwise
frame-level distances as an (n1, n2, s1, s2) lattice. Like an alignment, it can be passed wherever the
name of a built-in distance is accepted, to Score and to abx_on_cell:
import torch
from torch import Tensor
from fastabx import Score
def manhattan(a1: Tensor, a2: Tensor) -> Tensor:
"""Frame-level L1 distance, as a (n1, n2, s1, s2) lattice."""
n1, s1, d = a1.size()
n2, s2, _ = a2.size()
lattice = torch.cdist(a1.view(n1 * s1, d), a2.view(n2 * s2, d), p=1)
return lattice.view(n1, s1, n2, s2).transpose(1, 2)
score = Score(task, manhattan)
The two batches are (n, s, d) tensors, padded to a common length s; the alignment that consumes the
lattice is the one that knows the real lengths and reads only the valid sub-block of each pair. Only the
built-in "angular" and "cosine" names L2-normalize the dataset, so a custom distance receives the
features exactly as they are. Normalize them yourself, via Dataset.normalize_, if yours needs it.