Labels#

Labels are stored as interval DataFrames (onset_s, offset_s, labels, individual, trial, …) in a TSV file alongside each .nc dataset. See Labels for the user-facing workflow and the storage-format reference.


Interval operations#

ethograph.labels.intervals.add_interval(df, onset_s, offset_s, labels, individual, protected_label_ids=None, individual_rec='', confidence=1.0, labeling_method='manual')[source]#

Add an interval, resolving overlaps for the same subject.

If the new interval overlaps existing intervals for the same (actor, recipient) pair, the existing intervals are trimmed or split — unless their label ID is in protected_label_ids, in which case they are kept untouched. Another pair’s intervals are never touched: the same animal mounting bird A and preening bird B are two independent tracks.

Parameters:
  • df (pd.DataFrame) – Current intervals DataFrame.

  • onset_s (float) – Start and end times in seconds.

  • offset_s (float) – Start and end times in seconds.

  • labels (int) – Label class ID.

  • individual (str) – Individual performing the behaviour (actor).

  • protected_label_ids (set[int] | None) – Label IDs that must not be trimmed or split (e.g. labels belonging to inactive branches). None means no protection.

  • individual_rec (str) – Recipient of the behaviour; NO_RECIPIENT for a solo one.

  • confidence (float) – How sure this label is; HUMAN_CONFIDENCE for a hand-placed one.

  • labeling_method (str) – Who vouches for it; LABELING_MANUAL for a hand-placed one. A remnant trimmed off an existing interval keeps that interval’s method — nobody re-judged it.

Returns:

Updated intervals DataFrame sorted by onset_s.

Return type:

pd.DataFrame

Examples

>>> df = empty_intervals()
>>> df = add_interval(df, 0.0, 1.0, 1, "crow_A")
>>> df = add_interval(df, 0.5, 1.5, 2, "crow_A")
>>> len(df)
2
>>> float(df.iloc[0]["offset_s"])  # first interval trimmed
0.499
ethograph.labels.intervals.delete_interval(df, idx)[source]#

Drop interval by DataFrame index.

Return type:

DataFrame

ethograph.labels.intervals.find_interval_at(df, time_s, individual, label_ids=None, individual_rec=None)[source]#

Return DataFrame index of state interval containing time_s for one subject.

Point events are never returned here — use find_point_at() for those.

Parameters:
  • individual (str | None) – None matches any individual — used as a selection fallback when the current individual name doesn’t match what a loaded file stores.

  • label_ids (set[int] | None) – When given, only match intervals whose labels value is in this set. Useful for restricting to the active branch.

  • individual_rec (str | None) – Recipient to match; None matches any (same fallback role).

  • time. (Returns None if no non-background interval contains the)

Return type:

int | None

ethograph.labels.intervals.get_interval_bounds(df, idx)[source]#

Return (onset_s, offset_s, labels) for interval at idx.

Return type:

tuple[float, float, int]

ethograph.labels.intervals.empty_intervals()[source]#

Create an empty intervals DataFrame with the correct columns and dtypes.

Returns:

Empty DataFrame with the INTERVAL_COLUMNS.

Return type:

pd.DataFrame

Examples

>>> from ethograph.labels.intervals import empty_intervals
>>> df = empty_intervals()
>>> df.columns.tolist()[:3]
['onset_s', 'offset_s', 'labels']
>>> len(df)
0
ethograph.labels.intervals.purge_short_intervals(df, min_duration_s, label_thresholds_s=None)[source]#

Drop intervals shorter than a threshold.

Parameters:
  • df (pd.DataFrame) – Intervals DataFrame.

  • min_duration_s (float) – Default minimum duration in seconds.

  • label_thresholds_s (dict[int, float], optional) – Per-label minimum durations (overrides min_duration_s).

Returns:

Filtered DataFrame.

Return type:

pd.DataFrame

Examples

>>> df = add_interval(empty_intervals(), 0.0, 0.01, 1, "A")
>>> df = add_interval(df, 1.0, 2.0, 2, "A")
>>> purged = purge_short_intervals(df, min_duration_s=0.1)
>>> len(purged)
1
ethograph.labels.intervals.stitch_intervals(df, max_gap_s, individual=None)[source]#

Merge adjacent same-label intervals where gap <= max_gap_s.

Parameters:
  • df (pd.DataFrame) – Intervals DataFrame.

  • max_gap_s (float) – Maximum gap (seconds) between intervals to merge.

  • individual (str, optional) – If given, only stitch intervals for this individual.

Returns:

Stitched intervals DataFrame.

Return type:

pd.DataFrame

Examples

>>> df = add_interval(empty_intervals(), 0.0, 1.0, 1, "A")
>>> df = add_interval(df, 1.05, 2.0, 1, "A")
>>> stitched = stitch_intervals(df, max_gap_s=0.1)
>>> len(stitched)
1
>>> float(stitched.iloc[0]["offset_s"])
2.0
ethograph.labels.intervals.snap_boundaries(df, cp_times, max_expansion_s, max_shrink_s)[source]#

Snap interval onset/offset to nearest changepoint times.

Parameters:
  • df (pd.DataFrame) – Intervals DataFrame.

  • cp_times (np.ndarray) – Candidate changepoint times.

  • max_expansion_s (float) – Maximum allowed expansion (seconds).

  • max_shrink_s (float) – Maximum allowed shrinkage (seconds).

Returns:

Snapped intervals with overlaps resolved.

Return type:

pd.DataFrame

ethograph.labels.intervals.load_label_mapping(mapping_file='mapping.txt', order=None)[source]#

Load a label mapping with colors for visualization.

Parameters:
  • mapping_file (str or Path) – Path to the mapping file. Each line is <id> <name> [<branch>] [<event_type>] where branch is an optional integer (default 0) grouping labels into branches for independent labeling, and event_type is "state" (default) or "point".

  • order (list[int] or None) – Label IDs in the desired display sequence. If provided, overrides the default order (which follows label ID). Any ID not listed retains its default position.

Returns:

{label_id: {"name": str, "color": ndarray(3,), "order": int, "branch": int, "event_type": str}}.

Return type:

dict[int, dict]

Raises:

FileNotFoundError – If mapping_file does not exist.

Examples

>>> mappings = load_label_mapping("mapping.txt")
>>> mappings[1]["name"]
'walk'

Reorder labels for display without changing the file:

mappings = load_label_mapping("mapping.txt", order=[0, 3, 1, 2])

Draw labelled intervals on a plot:

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches

mappings = load_label_mapping("mapping.txt")
fig, ax = plt.subplots()
ax.plot(time, signal)
for _, row in intervals_df.iterrows():
    color = mappings[int(row["labels"])]["color"]
    ax.axvspan(row["onset_s"], row["offset_s"], alpha=0.5, color=color)
handles = [mpatches.Patch(color=m["color"], label=m["name"]) for m in mappings.values()]
ax.legend(handles=handles)
plt.show()
ethograph.labels.intervals.save_label_mapping(mapping_file, mappings)[source]#

Write a label mapping back to disk, preserving branch and event_type.

Lines have the form <id> <name> <branch> <event_type> for scalar IDs. The event_type column is omitted when it equals the default ("state") so files stay backward-compatible with older readers.

Parameters:
Return type:

None

ethograph.labels.intervals.load_mapping(mapping_file)[source]#

Load a class-name ↔ index mapping file.

The file is whitespace-delimited with lines <index> <name>.

Parameters:

mapping_file (str or Path) – Path to the mapping file.

Return type:

tuple[dict[str, int], dict[int, str]]

Returns:

  • class_to_idx (dict[str, int])

  • idx_to_class (dict[int, str])

Examples

>>> class_to_idx, idx_to_class = load_mapping("mapping.txt")
>>> class_to_idx["walk"]
1
>>> idx_to_class[1]
'walk'

Dense ↔ interval conversion (ML)#

ethograph.labels.ml.dense_to_intervals(dense_array, individuals, *, sample_rate=None, time_coord=None)[source]#

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:

Intervals with columns onset_s, offset_s, labels, individual. offset_s is inclusive (last sample of the segment).

Return type:

pd.DataFrame

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]
ethograph.labels.ml.intervals_to_dense(df, sample_rate, individuals, n_samples)[source]#

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:

Dense label array of shape (n_samples, len(individuals)), dtype int8. Background (unlabeled) time steps are 0.

Return type:

np.ndarray

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]
ethograph.labels.ml.get_labels_start_end_indices(col, bg_class=0)[source]#

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], :]

TSV storage#

ethograph.labels.tsv_store.labels_tsv_path(nc_path, suffix='')[source]#

Derive the labels TSV path from the .nc file path.

Return type:

Path

Examples

>>> labels_tsv_path("experiment/data.nc")
PosixPath('experiment/data_labels.tsv')
>>> labels_tsv_path("experiment/data.nc", suffix="_downsampled_100x")
PosixPath('experiment/data_downsampled_100x_labels.tsv')
ethograph.labels.tsv_store.load_labels_tsv(path)[source]#

Load labels from a TSV file.

Parameters:

path (str or Path) – Path to a _labels.tsv file.

Returns:

Columns: trial, onset_s, offset_s, labels (int), individual, labeling_method, changepoint_corrected, prediction_source.

Return type:

pd.DataFrame

Examples

>>> df = load_labels_tsv("experiment/data_labels.tsv")
>>> df[["trial", "onset_s", "offset_s", "labels", "individual"]].head()
   trial  onset_s  offset_s  labels individual
0      1     0.41     0.505       1      crow1
1      1     0.51     0.620       2      crow1
ethograph.labels.tsv_store.save_labels_tsv(path, df)[source]#

Save labels DataFrame to TSV. Uses atomic write (tmp + rename).

Parameters:
  • path (str or Path) – Destination path.

  • df (pd.DataFrame) – Labels DataFrame with required columns (see REQUIRED_COLUMNS).

Return type:

None

ethograph.labels.tsv_store.validate_labels_tsv(df, path='')[source]#

Validate that a labels DataFrame has all required columns and values.

Raises:

ValueError – If any of onset_s, offset_s, labels, individual, trial are missing from the DataFrame columns, or if any row is missing a value in one of REQUIRED_NONNULL_COLUMNS (offset_s aside, since a point event’s is legitimately blank).

Return type:

None

ethograph.labels.tsv_store.init_empty_labels(trials)[source]#

Create empty labels DataFrame.

Return type:

DataFrame

ethograph.labels.tsv_store.get_trial_from_tsv(all_df, trial)[source]#

Extract all rows for a single trial from the all-labels DataFrame.

Returns a DataFrame with the full TSV_COLUMNS set: trial, individual, labels, onset_s, offset_s, plus per-trial metadata columns. Callers that only need interval data can ignore the extras; set_trial_in_tsv already discards everything except INTERVAL_COLUMNS when writing back.

Return type:

DataFrame

ethograph.labels.tsv_store.set_trial_in_tsv(all_df, trial, trial_df)[source]#

Replace all rows for a trial in the all-labels DataFrame.

Preserves per-trial metadata columns from the existing rows. The columns added to files written before they existed (event_type, individual_rec, confidence, labeling_method) are filled in on the whole table first: the untouched trials must not end up with NaN where the rewritten trial has a value. Any other per-trial column a loaded file carries (a legacy human_verified, say) rides along with the trial’s previous value, so rewriting one trial never blanks it.

Return type:

DataFrame

ethograph.labels.tsv_store.get_trial_meta(all_df, trial)[source]#

Read per-trial metadata from columns. Returns dict with defaults for missing trials.

Return type:

dict

ethograph.labels.tsv_store.set_trial_meta_attr(all_df, trial, key, value)[source]#

Set a per-trial metadata column value for all rows of a trial.

Return type:

DataFrame


Predictions#

ethograph.labels.predictions.prediction_to_labels_and_confidence(pred)[source]#

Convert prediction array to dense labels and optional confidence.

Parameters:

pred (np.ndarray) – Shape (T, n_classes) for softmax probabilities, or (T,) for dense labels.

Return type:

tuple[np.ndarray, np.ndarray | None]

Returns:

  • labels (np.ndarray, shape (T,)) – Dense integer labels (argmax for softmax input).

  • confidence (np.ndarray or None) – Shape (T,) confidence scores. For softmax input: 1 - normalized_entropy. None if input is already dense labels.

class ethograph.labels.predictions.PredictionsStore(folder)[source]#

Read one ethograph.segment.inference prediction folder.

Example

store = PredictionsStore("labels/predictions_mstcn_20260101_000000")
labels_df, _ = store.load_all(dt)
confidence = store.get_confidence(trial=5, dt=dt, individual="A")
get_confidence(trial, dt, individual=None)[source]#

Per-frame confidence for one (trial, individual), from the run’s own probabilities.

Returns None when the run has no .npz (e.g. a hand-edited folder) or no key matches — an aid to review, never something a caller depends on.

Return type:

np.ndarray | None

load_all(dt, individual=None, **_ignored)[source]#

Every trial’s predictions, already postprocessed by the run itself.

Return type:

tuple[DataFrame, dict]


Crowsetta / pynapple converters#

class ethograph.labels.crowsetta_format.EthographSeq(onsets_s, offsets_s, labels, individuals=None, trials=None, annot_path='')#

Extended simple-seq format with individual and trial columns.

class ethograph.labels.converters.LabelConverter[source]#

Base class for converting external label sources to ethograph intervals.

Subclasses override extract() to pull intervals from their source (NWB, pynapple, crowsetta, …). The shared resolve_labels() method centralises the “TSV on disk → extract from source → empty” fallback chain used by every LoadResult-producing function in data_loader.

class ethograph.labels.converters.CrowsettaLabelConverter(file_path, format_name, name_to_id, individual='ind0')[source]#

Convert crowsetta annotation files to ethograph intervals.

Crowsetta labels are already in file-local time, so no trial table is needed for time conversion. If a trials_df is provided the first trial id is attached; otherwise trial=1.

class ethograph.labels.converters.PynappleLabelConverter(data, trials_ep=None)[source]#

Extract labels from pynapple IntervalSet objects.

Collects every nap.IntervalSet in the data dict except the one detect_trials() reads as trial boundaries. Each IntervalSet name becomes a label class.

ethograph.labels.converters.crowsetta_to_intervals(file_path, format_name, name_to_id, individual='ind0')[source]#

Convert a crowsetta annotation file to an intervals DataFrame.

Return type:

DataFrame

ethograph.labels.converters.extract_crowsetta_labels(file_path, format_name)[source]#

Extract unique string labels from a crowsetta annotation file.

Return type:

list[str]

ethograph.labels.converters.extend_mapping(names, mapping_path)[source]#

Name -> id for names in the mapping at mapping_path, appending any it lacks.

Existing classes keep their ids, branches and event types; a new name gets the next free id as a state class on branch 0. The file is rewritten only when a name was added, so one vocabulary serves every import format.

Returns the name -> id lookup and the names that were added.

Return type:

tuple[dict[str, int], list[str]]

ethograph.labels.converters.build_mapping_from_labels(string_labels)[source]#

Build a name->id mapping from a list of unique string labels.

Sorts labels alphabetically; 0 is reserved for ‘background’.

Return type:

dict[str, int]

ethograph.labels.converters.write_mapping_file(output_path, name_to_id)[source]#

Write a mapping file in ‘<id> <name>’ format.

Return type:

None


Export helpers#

ethograph.labels.export.enrich_labels_df(all_labels_df, nwb_alignment=None, keep_attrs=None, dt=None, metadata_df=None)[source]#

Enrich a raw labels DataFrame with computed columns for analysis export.

Takes the in-memory _all_labels_df (with columns onset_s, offset_s, labels, individual, trial) and adds session timing, duration, sequence info, and trial attributes from metadata_df and ds.attrs.

Parameters:
  • all_labels_df (pd.DataFrame) – Raw labels with required columns: onset_s, offset_s, labels, individual, trial.

  • nwb_alignment – Session metadata (for trial timing).

  • keep_attrs (list[str], optional) – Trial-level ds.attrs keys to include as extra columns (xarray only).

  • dt (TrialTree, optional) – Xarray data tree (only needed for keep_attrs and session name).

  • metadata_df (pd.DataFrame, optional) – Trial metadata table. Columns are merged per trial into the enriched output. If metadata_df contains “poscat” or “num_pellets”, those are used instead of falling back to ds.attrs.

Returns:

Enriched DataFrame with one row per non-background segment.

Return type:

pd.DataFrame

ethograph.labels.export.correct_offsets_trial(df)[source]#

Apply gap correction to a single trial’s interval DataFrame.

For each subject (actor + recipient), pulls back offset_s when the gap to the next onset is smaller than eps so pynapple can resolve all intervals.

Works on the per-trial format (columns: trial, onset_s, offset_s, labels, individual) returned by app_state.get_trial_intervals().

Returns:

Corrected DataFrame, number of offsets corrected, number of negative gaps found.

Return type:

tuple[pd.DataFrame, int, int]


Plotting#

ethograph.labels.plots.draw_label_rectangle(ax, start_time, end_time, labels, label_mappings, is_main=True, fraction=None, alpha=0.8)[source]#

Draw a label rectangle on a matplotlib axis.

Parameters:
  • ax (Axes) – Matplotlib axis to plot on

  • start_time (float) – Start time of the label

  • end_time (float) – End time of the label

  • labels (int) – Label class ID for color mapping

  • label_mappings (Dict[int, Dict]) – Dict mapping label IDs to color info

  • is_main (bool) – If True, draw full-height rectangle; if False, draw small rectangle at top

  • fraction (Optional[float]) – Height fraction for non-main rectangles

Return type:

None

Example:

fig, ax = plt.subplots()
ax.plot(time, signal)
draw_label_rectangle(ax, 1.2, 3.5, label_id=1, label_mappings=label_mappings)
ethograph.labels.plots.plot_label_segments(ax, df, label_mappings, individual=None, is_main=True, fraction=0.2, alpha=0.8)[source]#

Plot label segments from an intervals DataFrame.

Parameters:
  • ax (Axes) – Matplotlib axis to plot on

  • df (DataFrame) – Intervals DataFrame with columns onset_s, offset_s, labels, individual

  • label_mappings (Dict[int, Dict]) – Dict mapping label IDs to color info

  • individual (Optional[str]) – If given, only plot segments for this individual

  • is_main (bool) – If True, plot full-height rectangles; if False, plot small rectangles at top

  • fraction (float) – Height fraction for non-main rectangles

Return type:

None

Example:

import ethograph as eto
from ethograph.labels.intervals import load_label_mapping

dt = eto.open("data.nc")
label_mappings = load_label_mapping("mapping.txt")

fig, ax = plt.subplots()
# df is an intervals DataFrame with onset_s, offset_s, labels, individual
plot_label_segments(ax, df, label_mappings)
plt.show()
ethograph.labels.plots.plot_label_segments_multirow(ax, df, label_mappings, row_index=0, row_spacing=0.8, rect_height=0.7, alpha=0.7, individual=None)[source]#

Plot label segments at a specific row position.

Useful for comparing ground truth vs. predictions on the same axis by placing each on a different row.

Parameters:
  • ax (Axes) – Matplotlib axis to plot on

  • df (DataFrame) – Intervals DataFrame with columns onset_s, offset_s, labels, individual

  • label_mappings (Dict[int, Dict[str, str]]) – Dict mapping label IDs to color info

  • row_index (int) – Row number (0-based) for vertical positioning

  • row_spacing (float) – Vertical spacing between rows

  • rect_height (float) – Height of each rectangle

  • alpha (float) – Transparency of rectangles

  • individual (Optional[str]) – If given, only plot segments for this individual

Return type:

None

Example:

import ethograph as eto
from ethograph.labels.intervals import load_label_mapping

dt = eto.open("data.nc")
pred_dt = eto.open("predictions.nc")
label_mappings = load_label_mapping("mapping.txt")

fig, ax = plt.subplots()
ax.set_yticks([0, 0.8])
ax.set_yticklabels(["ground truth", "predictions"])

# gt_df, pred_df are intervals DataFrames with onset_s, offset_s, labels, individual
gt_df = ...
pred_df = ...

plot_label_segments_multirow(ax, gt_df, label_mappings, row_index=0)
plot_label_segments_multirow(ax, pred_df, label_mappings, row_index=1)
plt.show()