"""Dense (array-based) label operations for ML pipelines.
This module provides tools for converting between the interval-based label
format (used by the GUI and TSV storage) and dense integer arrays (used by
ML models). It also contains post-processing operations commonly applied
to model predictions before evaluation or storage.
Typical ML workflow
-------------------
1. **Load labels from TSV** → ``pd.DataFrame`` with ``onset_s``, ``offset_s``,
``labels``, ``individual`` (plus ``n_samples`` per-trial metadata).
2. **Convert to dense** → ``intervals_to_dense(df, sample_rate, individuals, n_samples)``
gives an ``(n_samples, n_individuals)`` int8 array ready for training.
3. **Run model** → get a dense prediction array of shape ``(T,)`` or ``(T, n_classes)``.
4. **Convert back** → ``dense_to_intervals(pred, individuals, sample_rate=sr)``
gives an intervals DataFrame for storage or evaluation.
5. **Post-process** on the intervals — purge, stitch, snap — through
:func:`ethograph.features.changepoints.correct_changepoints`; there is no
dense post-processing.
The ``n_samples`` value stored in the TSV file (per-trial metadata) tells you
exactly how long the dense array should be — you only need to additionally
know the ``sample_rate`` to drive the conversion.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from ethograph.labels.intervals import _rows_to_df, states_only
# ── Primitives ───────────────────────────────────────────────────────────
def _get_segments(col, bg_class=0):
"""Find contiguous labeled segments in a 1-D array.
Example: ``[0,1,1,1,0,2,2]`` → ``[(1,1,4), (2,5,7)]``
Each tuple is ``(label, start_index, end_index_exclusive)``.
"""
padded = np.concatenate([[-1], col, [-1]])
change_indices = np.nonzero(padded[:-1] != padded[1:])[0]
segments = []
for i in range(len(change_indices) - 1):
start = change_indices[i]
end = change_indices[i + 1]
label = int(col[start])
if label != bg_class:
segments.append((label, start, end))
return segments
def _get_labels_start_end_times(col, time_coord, individual, bg_class=0):
"""Convert segments to time intervals (inclusive end)."""
segments = _get_segments(col, bg_class)
return [
{
"onset_s": float(time_coord[start]),
"offset_s": float(time_coord[end - 1]),
"labels": label,
"individual": individual,
}
for label, start, end in segments
]
# ── Interval ↔ Dense conversion ─────────────────────────────────────────
[docs]
def dense_to_intervals(
dense_array: np.ndarray,
individuals: list[str],
*,
sample_rate: float | None = None,
time_coord: np.ndarray | None = None,
) -> pd.DataFrame:
"""Convert a dense label array to an intervals DataFrame.
Provide either *sample_rate* (uniform spacing starting at t = 0) or an
explicit *time_coord* array.
Parameters
----------
dense_array : np.ndarray
Shape ``(n_samples,)`` for a single individual, or
``(n_samples, n_individuals)`` for multiple.
individuals : list[str]
Individual identifiers — length must match the second axis.
sample_rate : float, optional
Sampling rate in Hz. Timestamps are computed as
``np.arange(n_samples) / sample_rate``.
time_coord : np.ndarray, optional
Explicit time array of length ``n_samples``. Use this when timestamps
are non-uniform or do not start at zero.
Returns
-------
pd.DataFrame
Intervals with columns ``onset_s``, ``offset_s``, ``labels``,
``individual``. ``offset_s`` is **inclusive** (last sample of the
segment).
Raises
------
ValueError
If neither *sample_rate* nor *time_coord* is given, or if the number
of individuals does not match the array width.
Examples
--------
Convert a 1-D dense array at 10 Hz:
>>> import numpy as np
>>> from ethograph.labels.ml import dense_to_intervals
>>> labels = np.array([0, 1, 1, 1, 0, 2, 2])
>>> df = dense_to_intervals(labels, ["crow_A"], sample_rate=10.0)
>>> df[["onset_s", "offset_s", "labels"]].values.tolist()
[[0.1, 0.3, 1], [0.5, 0.6, 2]]
With explicit timestamps:
>>> times = np.array([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6])
>>> df = dense_to_intervals(labels, ["crow_A"], time_coord=times)
>>> df["onset_s"].tolist()
[0.1, 0.5]
"""
dense_array = np.asarray(dense_array)
if sample_rate is None and time_coord is None:
raise ValueError("Provide either sample_rate or time_coord")
if time_coord is None:
time_coord = np.arange(dense_array.shape[0]) / sample_rate
else:
time_coord = np.asarray(time_coord)
if dense_array.ndim == 1:
dense_array = dense_array[:, np.newaxis]
if dense_array.shape[1] != len(individuals):
raise ValueError(f"dense_array has {dense_array.shape[1]} columns but {len(individuals)} individuals given")
rows: list[dict] = []
for ind_idx, ind_name in enumerate(individuals):
col = dense_array[:, ind_idx]
rows.extend(_get_labels_start_end_times(col, time_coord, str(ind_name)))
return _rows_to_df(rows)
[docs]
def intervals_to_dense(
df: pd.DataFrame,
sample_rate: float,
individuals: list[str],
n_samples: int,
) -> np.ndarray:
"""Convert an intervals DataFrame to a dense label array.
Each interval is mapped onto the nearest sample indices using
``round(time * sample_rate)``. Overlapping intervals for the same
individual are resolved by last-write-wins.
Parameters
----------
df : pd.DataFrame
Intervals DataFrame with columns ``onset_s``, ``offset_s``, ``labels``,
``individual``.
sample_rate : float
Sampling rate in Hz (e.g. 30.0 for 30 fps video features).
individuals : list[str]
Individual identifiers. The output column order matches this list.
n_samples : int
Number of output time steps. Typically available as per-trial
``n_samples`` metadata in the TSV file.
Returns
-------
np.ndarray
Dense label array of shape ``(n_samples, len(individuals))``, dtype
``int8``. Background (unlabeled) time steps are 0.
Examples
--------
>>> import pandas as pd
>>> from ethograph.labels.ml import intervals_to_dense
>>> df = pd.DataFrame(
... {
... "onset_s": [0.1, 0.5],
... "offset_s": [0.3, 0.6],
... "labels": [1, 2],
... "individual": ["A", "A"],
... }
... )
>>> dense = intervals_to_dense(df, sample_rate=10.0, individuals=["A"], n_samples=7)
>>> dense[:, 0].tolist()
[0, 1, 1, 1, 0, 2, 2]
"""
dense = np.zeros((n_samples, len(individuals)), dtype=np.int8)
ind_to_idx = {name: i for i, name in enumerate(individuals)}
df = states_only(df)
for _, row in df.iterrows():
ind_idx = ind_to_idx.get(row["individual"])
if ind_idx is None:
continue
start_idx = int(round(row["onset_s"] * sample_rate))
end_idx = int(round(row["offset_s"] * sample_rate))
start_idx = max(0, start_idx)
end_idx = min(n_samples - 1, end_idx)
dense[start_idx : end_idx + 1, ind_idx] = int(row["labels"])
return dense
# ── Segment index extraction ────────────────────────────────────────────
[docs]
def get_labels_start_end_indices(col, bg_class=0):
"""Return segment boundaries as sample indices (exclusive end).
Useful for slicing dense arrays or computing segment-level metrics.
Parameters
----------
col : array-like
1-D dense label array.
bg_class : int
Background class to ignore (default 0).
Returns
-------
labels : list[int]
Label class for each segment.
starts : list[int]
Start index (inclusive) of each segment.
ends : list[int]
End index (**exclusive**) — use ``array[start:end]`` to slice.
Examples
--------
>>> from ethograph.labels.ml import get_labels_start_end_indices
>>> labels, starts, ends = get_labels_start_end_indices([0, 1, 1, 1, 0, 2, 2])
>>> labels
[1, 2]
>>> starts
[1, 5]
>>> ends
[4, 7]
>>> # To extract the first segment from a feature array:
>>> # segment_features = features[starts[0]:ends[0], :]
"""
segments = _get_segments(col, bg_class)
labels = [s[0] for s in segments]
starts = [s[1] for s in segments]
ends = [s[2] for s in segments]
return labels, starts, ends