Skip to content

Dynamic time warping trajectory comparison

This example contrasts the backward-compatible symmetric1 raw-cost contract with the normalizable symmetric2 variant.

A one-sample shift

import numpy as np
from eyetrajectoriespy import dynamic_time_warping_distance

a = np.array([
    [0.0],
    [0.0],
    [1.0],
])

b = np.array([
    [0.0],
    [1.0],
    [1.0],
])

legacy = dynamic_time_warping_distance(
    a,
    b,
    step_pattern="symmetric1",
    return_path=True,
)

normalizable = dynamic_time_warping_distance(
    a,
    b,
    step_pattern="symmetric2",
    normalize=True,
    return_path=True,
)

print(legacy.raw_distance)
print(normalizable.raw_distance)
print(normalizable.normalized_distance)

Both alignments can remove this one-sample shift. That can be useful when local progression rate is nuisance variation and problematic when the shift is the psychological effect itself.

Inspect the weighted path audit

print(normalizable.path)
print(normalizable.local_distances)
print(normalizable.step_weights)
print(normalizable.weighted_local_costs)

assert np.isclose(
    normalizable.raw_distance,
    normalizable.weighted_local_costs.sum(),
)

For symmetric2, diagonal advances receive weight two and horizontal/vertical advances weight one. The initial matched pair also has weight two. The normalized distance divides the raw cumulative cost by n_a + n_b.

Constrain the index warp

diagonal_only = dynamic_time_warping_distance(
    a,
    b,
    step_pattern="symmetric2",
    normalize=True,
    window_radius=0,
)

print(diagonal_only)

A zero-radius band forces same-index alignment. Window radius is therefore part of the estimand and should not be tuned after inspecting group differences.

Plot the alignment

from eyetrajectoriespy import plot_dynamic_time_warping_alignment

ax = plot_dynamic_time_warping_alignment(normalizable)

The same-index diagonal is shown as a reference. Departures from it make the amount and location of index warping visible.

Pairwise planar gaze trajectories

from eyetrajectoriespy import (
    TrajectorySet,
    pairwise_dynamic_time_warping_distances,
)

time = np.array([0.0, 0.5, 1.0])
values = np.array([
    [[0.0, 0.0], [0.0, 0.0], [1.0, 0.0]],
    [[0.0, 0.0], [1.0, 0.0], [1.0, 0.0]],
    [[0.0, 0.0], [0.0, 1.0], [1.0, 0.0]],
])

gaze = TrajectorySet(
    time=time,
    values=values,
    curve_ids=("A", "B", "C"),
    dimension_names=("x", "y"),
    coordinate_system="degrees",
    time_unit="s",
)

matrix = pairwise_dynamic_time_warping_distances(
    gaze,
    dimensions=("x", "y"),
    step_pattern="symmetric2",
    normalize=True,
    window_radius=1,
)

print(matrix)

The TrajectorySet time grid describes the source representation, but numeric timestamps are not used by DTW.

Manuscript-oriented text

from eyetrajectoriespy import dynamic_time_warping_reporting_text

print(dynamic_time_warping_reporting_text(normalizable))

Report the step pattern, normalization rule, window, coordinate representation, dimensions/units, sequence lengths, upstream preprocessing, and whether latency was analyzed separately.

The executable counterpart is examples/dynamic_time_warping.py.