Skip to content

Commit 96547c3

Browse files
ElektrikAkarclaude
andcommitted
Phase 4: CI wheels, cross-validation tests, Python examples
CI workflow (.github/workflows/python-wheels.yml): - cibuildwheel for Linux/macOS/Windows, Python 3.9-3.13 - nanobind STABLE_ABI for fewer wheels - Gurobi/HiGHS disabled for wheel builds - Auto-publish to PyPI on tagged releases Cross-validation tests (15 tests): - C++ dtw_distance matches Problem.dist_by_ind for all variants - DenseDistanceMatrix matches pairwise direct computation - DTWClustering sugar produces consistent results with raw fast_pam - DDTW = derivative_transform + standard DTW - ADTW(penalty=0) = standard DTW - Soft-DTW(gamma->0) approaches standard DTW Python examples: - 01_quickstart.py: DTW distance + clustering in 10 lines - 02_dtw_variants.py: compare all 5 DTW variants - 03_clustering_evaluation.py: clustering with silhouette/DBI scoring Total: 100 Python tests pass in 0.23s. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent df716e8 commit 96547c3

5 files changed

Lines changed: 544 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
name: Python wheels
2+
3+
on:
4+
push:
5+
branches: [main, Claude]
6+
tags: ['v*']
7+
pull_request:
8+
branches: ['**']
9+
10+
jobs:
11+
build_wheels:
12+
name: Wheels on ${{ matrix.os }}
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
os: [ubuntu-latest, macos-latest, windows-latest]
17+
runs-on: ${{ matrix.os }}
18+
steps:
19+
- uses: actions/checkout@v4
20+
with:
21+
submodules: true
22+
23+
- uses: pypa/cibuildwheel@v2.21
24+
env:
25+
# Build for Python 3.9-3.13; nanobind STABLE_ABI means fewer wheels
26+
CIBW_BUILD: "cp39-* cp310-* cp311-* cp312-* cp313-*"
27+
CIBW_SKIP: "*-musllinux_* *-win32 *-manylinux_i686"
28+
CIBW_ARCHS_MACOS: "x86_64 arm64"
29+
30+
# Disable commercial/heavy solvers for wheel builds
31+
CIBW_CONFIG_SETTINGS: >-
32+
cmake.args="-DDTWC_BUILD_PYTHON=ON;-DDTWC_ENABLE_GUROBI=OFF;-DDTWC_ENABLE_HIGHS=OFF"
33+
34+
# macOS needs libomp for OpenMP
35+
CIBW_BEFORE_BUILD_MACOS: "brew install libomp || true"
36+
37+
# Test the built wheel
38+
CIBW_TEST_REQUIRES: "pytest numpy"
39+
CIBW_TEST_COMMAND: "pytest {project}/tests/python -v --tb=short"
40+
CIBW_BUILD_VERBOSITY: 1
41+
42+
- uses: actions/upload-artifact@v4
43+
with:
44+
name: wheels-${{ matrix.os }}
45+
path: wheelhouse/*.whl
46+
47+
build_sdist:
48+
name: Source distribution
49+
runs-on: ubuntu-latest
50+
steps:
51+
- uses: actions/checkout@v4
52+
- run: pip install build && python -m build --sdist
53+
- uses: actions/upload-artifact@v4
54+
with:
55+
name: sdist
56+
path: dist/*.tar.gz
57+
58+
test_python:
59+
name: Test Python ${{ matrix.python }} on ${{ matrix.os }}
60+
strategy:
61+
matrix:
62+
os: [ubuntu-latest, windows-latest]
63+
python: ['3.10', '3.12']
64+
runs-on: ${{ matrix.os }}
65+
steps:
66+
- uses: actions/checkout@v4
67+
- uses: actions/setup-python@v5
68+
with:
69+
python-version: ${{ matrix.python }}
70+
- name: Install and test
71+
run: |
72+
pip install ".[test]" -v
73+
pytest tests/python/ -v --tb=short
74+
75+
publish:
76+
name: Publish to PyPI
77+
if: startsWith(github.ref, 'refs/tags/v')
78+
needs: [build_wheels, build_sdist, test_python]
79+
runs-on: ubuntu-latest
80+
permissions:
81+
id-token: write
82+
steps:
83+
- uses: actions/download-artifact@v4
84+
with:
85+
path: dist
86+
merge-multiple: true
87+
- uses: pypa/gh-action-pypi-publish@release/v1

python/examples/01_quickstart.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""
2+
DTWC++ Quick Start — DTW distance and clustering in 10 lines.
3+
4+
Install: pip install dtwcpp
5+
"""
6+
7+
import numpy as np
8+
import dtwcpp
9+
10+
# --- 1. Compute DTW distance between two time series ---
11+
x = [1.0, 2.0, 3.0, 4.0, 5.0]
12+
y = [2.0, 4.0, 6.0, 3.0, 1.0]
13+
14+
d = dtwcpp.dtw_distance(x, y)
15+
print(f"DTW distance: {d}")
16+
17+
# Banded DTW (Sakoe-Chiba constraint) — much faster for long series
18+
d_banded = dtwcpp.dtw_distance(x, y, band=2)
19+
print(f"DTW distance (band=2): {d_banded}")
20+
21+
# --- 2. Cluster time series with sklearn-like API ---
22+
rng = np.random.RandomState(42)
23+
24+
# Create 3 groups of 10 series each (length 50)
25+
group_a = rng.randn(10, 50) # centered at 0
26+
group_b = rng.randn(10, 50) + 5 # centered at 5
27+
group_c = rng.randn(10, 50) + 10 # centered at 10
28+
X = np.vstack([group_a, group_b, group_c])
29+
30+
clf = dtwcpp.DTWClustering(n_clusters=3, band=10)
31+
labels = clf.fit_predict(X)
32+
33+
print(f"\nCluster labels: {labels}")
34+
print(f"Inertia (total cost): {clf.inertia_:.2f}")
35+
print(f"Medoid indices: {clf.medoid_indices_}")
36+
print(f"Iterations: {clf.n_iter_}")
37+
38+
# Predict cluster for new data
39+
new_series = rng.randn(3, 50) + 5 # should be assigned to group_b's cluster
40+
predicted = clf.predict(new_series)
41+
print(f"Predicted labels for new data: {predicted}")

python/examples/02_dtw_variants.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
DTWC++ DTW Variants — Compare all 5 DTW distance measures.
3+
4+
Demonstrates: Standard DTW, DDTW, WDTW, ADTW, Soft-DTW.
5+
"""
6+
7+
import numpy as np
8+
import dtwcpp
9+
10+
# Create two time series
11+
np.random.seed(42)
12+
t = np.linspace(0, 4 * np.pi, 200)
13+
x = np.sin(t).tolist()
14+
y = np.sin(t + 0.5).tolist() # phase-shifted version
15+
16+
print("=== DTW Variant Comparison ===")
17+
print(f"Series length: {len(x)}")
18+
print()
19+
20+
# Standard DTW
21+
d_std = dtwcpp.dtw_distance(x, y, band=-1)
22+
print(f"Standard DTW: {d_std:.4f}")
23+
24+
# Banded DTW (Sakoe-Chiba)
25+
d_banded = dtwcpp.dtw_distance(x, y, band=20)
26+
print(f"Banded DTW (b=20): {d_banded:.4f}")
27+
28+
# DDTW — Derivative DTW (shape-based, ignores amplitude)
29+
d_ddtw = dtwcpp.ddtw_distance(x, y, band=-1)
30+
print(f"DDTW (derivative): {d_ddtw:.4f}")
31+
32+
# WDTW — Weighted DTW (penalizes off-diagonal alignment)
33+
d_wdtw_lo = dtwcpp.wdtw_distance(x, y, band=-1, g=0.01)
34+
d_wdtw_hi = dtwcpp.wdtw_distance(x, y, band=-1, g=0.5)
35+
print(f"WDTW (g=0.01): {d_wdtw_lo:.4f} (lenient)")
36+
print(f"WDTW (g=0.50): {d_wdtw_hi:.4f} (strict)")
37+
38+
# ADTW — Amerced DTW (penalizes non-diagonal steps)
39+
d_adtw_lo = dtwcpp.adtw_distance(x, y, band=-1, penalty=0.1)
40+
d_adtw_hi = dtwcpp.adtw_distance(x, y, band=-1, penalty=5.0)
41+
print(f"ADTW (p=0.1): {d_adtw_lo:.4f} (lenient)")
42+
print(f"ADTW (p=5.0): {d_adtw_hi:.4f} (strict)")
43+
44+
# Soft-DTW — Differentiable (for gradient-based optimization)
45+
d_soft_lo = dtwcpp.soft_dtw_distance(x, y, gamma=0.01)
46+
d_soft_hi = dtwcpp.soft_dtw_distance(x, y, gamma=10.0)
47+
print(f"Soft-DTW (g=0.01): {d_soft_lo:.4f} (~ hard DTW)")
48+
print(f"Soft-DTW (g=10): {d_soft_hi:.4f} (smooth)")
49+
50+
# Soft-DTW gradient — unique to Soft-DTW, enables optimization
51+
grad = dtwcpp.soft_dtw_gradient(x, y, gamma=1.0)
52+
print(f"\nSoft-DTW gradient norm: {np.linalg.norm(grad):.4f}")
53+
print(f"Gradient shape: {len(grad)} (same as input series)")
54+
55+
# --- Preprocessing: derivative transform ---
56+
dx = dtwcpp.derivative_transform(x)
57+
print(f"\nDerivative transform: first 5 values = {[f'{v:.3f}' for v in dx[:5]]}")
58+
59+
# --- Z-normalization ---
60+
z = dtwcpp.z_normalize([10, 20, 30, 40, 50])
61+
print(f"Z-normalize([10..50]) = [{', '.join(f'{v:.3f}' for v in z)}]")
62+
print(f" Mean = {np.mean(z):.6f}, Std = {np.std(z):.6f}")
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""
2+
DTWC++ Clustering Evaluation — Compare clustering quality across DTW variants.
3+
4+
Demonstrates: DTWClustering with different variants, silhouette scores, DBI.
5+
"""
6+
7+
import numpy as np
8+
import dtwcpp
9+
10+
# Generate synthetic data: 3 well-separated clusters
11+
rng = np.random.RandomState(42)
12+
n_per_cluster = 10
13+
length = 30
14+
15+
clusters = []
16+
for center in [0, 50, 100]:
17+
for _ in range(n_per_cluster):
18+
series = center + rng.randn(length) * 2
19+
clusters.append(series)
20+
21+
X = np.array(clusters)
22+
true_labels = np.array([0] * n_per_cluster + [1] * n_per_cluster + [2] * n_per_cluster)
23+
24+
print("=== Clustering Evaluation ===")
25+
print(f"Data: {X.shape[0]} series of length {X.shape[1]}")
26+
print(f"True clusters: {np.bincount(true_labels)}")
27+
print()
28+
29+
# Compare DTW variants for clustering
30+
variants = ["standard", "ddtw", "wdtw", "adtw"]
31+
32+
for variant in variants:
33+
kwargs = {}
34+
if variant == "wdtw":
35+
kwargs["wdtw_g"] = 0.1
36+
elif variant == "adtw":
37+
kwargs["adtw_penalty"] = 1.0
38+
39+
clf = dtwcpp.DTWClustering(
40+
n_clusters=3, variant=variant, band=-1, max_iter=100, **kwargs
41+
)
42+
labels = clf.fit_predict(X)
43+
44+
# Check cluster recovery accuracy
45+
# (labels may be permuted — check if partition matches)
46+
from itertools import permutations
47+
best_acc = 0
48+
for perm in permutations(range(3)):
49+
remapped = np.array([perm[l] for l in labels])
50+
acc = np.mean(remapped == true_labels)
51+
best_acc = max(best_acc, acc)
52+
53+
print(f"{variant:10s} — cost: {clf.inertia_:8.2f}, "
54+
f"iters: {clf.n_iter_:2d}, "
55+
f"accuracy: {best_acc:.0%}")
56+
57+
# --- Detailed evaluation with Problem class ---
58+
print("\n=== Detailed Scoring (Standard DTW) ===")
59+
60+
prob = dtwcpp.Problem("eval")
61+
prob.set_data(X.tolist(), [f"s{i}" for i in range(len(X))])
62+
prob.band = -1
63+
prob.fill_distance_matrix()
64+
65+
result = dtwcpp.fast_pam(prob, n_clusters=3)
66+
print(f"FastPAM result: {result}")
67+
68+
# Apply labels to Problem for scoring
69+
prob.set_number_of_clusters(3)
70+
prob.clusters_ind = result.labels
71+
prob.centroids_ind = result.medoid_indices
72+
73+
sil = dtwcpp.silhouette(prob)
74+
sil_mean = np.mean(sil)
75+
print(f"Mean silhouette: {sil_mean:.3f}")
76+
77+
dbi = dtwcpp.davies_bouldin_index(prob)
78+
print(f"Davies-Bouldin Index: {dbi:.3f}")
79+
80+
# --- Distance matrix visualization ---
81+
print(f"\nDistance matrix size: {prob.size} × {prob.size}")
82+
print(f"Max distance: {prob.max_distance():.2f}")
83+
print(f"Medoid indices: {result.medoid_indices}")
84+
print(f"Medoid names: {[prob.get_name(i) for i in result.medoid_indices] if hasattr(prob, 'get_name') else 'N/A'}")

0 commit comments

Comments
 (0)