Skip to content

Phoneme Classification with Shapelets

Dataset: Phoneme — log-periodograms (256 frequency points each) of spoken sounds, five phoneme classes: aa (as in dark), ao (as in water), dcl (the d closure), iy (as in she), and sh (as in she). Each curve is a sound's frequency spectrum; the label is the phoneme.

The central question is: can short, discriminative subsequences — shapelets — extract enough spectral structure to classify phonemes? Shapelets are amplitude-based local patterns in the log-periodogram; once discovered, each curve is represented as a vector of minimum-distance scores, one per shapelet. This feature matrix is fully interpretable: you can examine which frequency regions drive the separation.

This example uses three acoustically distinct classes — aa (vowel), sh (fricative), and dcl (stop) — with 30 curves per class. The pipeline follows the four-step fdars shapelet API: discover_shapelets → shapelet_transform_fit → shapelet_transform → shapelet_classifier_fit. Unlike elastic shape analysis (see Phoneme — Shape Classification), shapelets do not warp the frequency axis; they match amplitude patterns at specific spectral locations.

API contract: discover_shapelets returns a summary dict, not arrays

discover_shapelets returns {'n_shapelets': int, 'quality': str} — a summary of the discovery result. It does not return the raw shapelet arrays. To obtain the shapelet handle for transforming data, use shapelet_transform_fit. Also, when constructing a subset from a fancy-indexed slice, wrap it with np.ascontiguousarray(..., dtype=np.float64) — the PyO3 buffer protocol requires C-contiguous float64 data.

Phoneme spectra: the three-class subset

We load the phoneme dataset and extract 30 curves per class from the three most acoustically distinct phonemes. The vowel (aa), fricative (sh), and stop (dcl) occupy very different spectral regions, which is why this subset is a natural target for shapelet discovery.

import numpy as np
from docs_fig import fig, render
from docs_data import load_phoneme

freq, X, meta = load_phoneme()
ph = meta["phoneme"].to_numpy()

# 3 acoustically distinct classes: vowel, fricative, stop
c_sel = ["aa", "sh", "dcl"]
n_per = 30
idx = np.concatenate([np.where(ph == c)[0][:n_per] for c in c_sel])
X_sub = np.ascontiguousarray(X[idx], dtype=np.float64)   # (90, 256)
y_sub = np.array([i for i in range(3) for _ in range(n_per)], dtype=np.int64)

palette = {"aa": "#3f51b5", "sh": "#6f42c1", "dcl": "#198754"}
labels  = {"aa": "aa (vowel)", "sh": "sh (fricative)", "dcl": "dcl (stop)"}

f, axes = fig(ncols=3, figsize=(9.5, 3.0), sharey=True)
for ax, cls in zip(axes, c_sel):
    pool = X_sub[y_sub == c_sel.index(cls)]
    ax.plot(freq, pool[:10].T, color=palette[cls], lw=0.5, alpha=0.45)
    ax.plot(freq, pool.mean(axis=0), color=palette[cls], lw=2.2,
            label="mean")
    ax.set_title(labels[cls], color=palette[cls])
    ax.set_xlabel("frequency bin")
axes[0].set_ylabel("log-periodogram")
f.suptitle("Three-class phoneme subset — log-periodogram spectra", y=1.04)
print(render(f))
print("FDARS_FENCE_OK")
image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/
FDARS_FENCE_OK

The three classes occupy distinct spectral regions. The vowel aa concentrates energy in the low-frequency bins; the fricative sh is dominated by high-frequency noise; the stop dcl sits in between with a flatter, lower-amplitude profile. These amplitude differences — rather than peak-position shifts — are exactly what shapelets are designed to capture.

Shapelet discovery and feature extraction

Shapelet discovery searches candidate subsequences of the training curves and selects those that maximally separate classes by information gain. The result of discover_shapelets is a summary dict reporting how many shapelets were selected and the quality criterion used. To get the actual shapelet-distance feature matrix, call shapelet_transform_fit (which also runs the discovery internally) and then shapelet_transform.

import numpy as np
from docs_fig import fig, render
from docs_data import load_phoneme
from fdars.shapelet import (discover_shapelets, shapelet_transform_fit,
                             shapelet_transform)

freq, X, meta = load_phoneme()
ph = meta["phoneme"].to_numpy()

c_sel = ["aa", "sh", "dcl"]
n_per = 30
idx = np.concatenate([np.where(ph == c)[0][:n_per] for c in c_sel])
X_sub = np.ascontiguousarray(X[idx], dtype=np.float64)
y_sub = np.array([i for i in range(3) for _ in range(n_per)], dtype=np.int64)

# 80/20 train-test split per class
tr_idx = np.concatenate([np.where(y_sub == i)[0][:24] for i in range(3)])
te_idx = np.concatenate([np.where(y_sub == i)[0][24:] for i in range(3)])
X_tr, y_tr = X_sub[tr_idx], y_sub[tr_idx]   # (72, 256)
X_te, y_te = X_sub[te_idx], y_sub[te_idx]   # (18, 256)

# Step 1: discovery summary (NOT a list of shapelet arrays — see note above)
disc = discover_shapelets(X_tr, y_tr, max_candidates=200, seed=42)
n_disc = disc["n_shapelets"]
qual   = disc["quality"]

# Step 2: fit transform (stores the shapelets internally in the handle)
stfit   = shapelet_transform_fit(X_tr, y_tr, max_candidates=200, seed=42)
feat_tr = shapelet_transform(stfit, X_tr)   # (72, K)
feat_te = shapelet_transform(stfit, X_te)   # (18, K)

palette = ["#3f51b5", "#6f42c1", "#198754"]
f, ax = fig(figsize=(8.0, 4.0))
im = ax.imshow(feat_tr, aspect="auto", cmap="viridis")
for i in range(1, 3):
    ax.axhline(24 * i - 0.5, color="white", lw=1.3, ls="--")
ax.set(
    title=f"Shapelet feature matrix — {stfit.n_shapelets} shapelets × 72 training curves"
          f"\n(quality: {qual}; horizontal lines separate the 3 classes)",
    xlabel="shapelet index",
    ylabel="curve index",
)
f.colorbar(im, ax=ax, shrink=0.75, label="min-distance score")
print(render(f))
print("FDARS_FENCE_OK")
image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/
FDARS_FENCE_OK

The feature matrix has shape (72, K) — 72 training curves × K shapelets. Each entry is the minimum distance between a curve and one shapelet subsequence. Class structure is visible in the heatmap: the three class blocks (separated by dashed lines) show different distance patterns, particularly in the left portion of the shapelet index where the most discriminative patterns concentrate.

Classification and accuracy

shapelet_classifier_fit combines shapelet discovery and a downstream classifier (kNN or LDA) into a single call. It returns a handle with .train_accuracy, .n_shapelets, .classes, and a .predict() method for new data. We compare kNN (k=1) and LDA to see which generalises better on the 18-curve test set.

import numpy as np
from docs_fig import fig, render
from docs_data import load_phoneme
from fdars.shapelet import shapelet_classifier_fit

freq, X, meta = load_phoneme()
ph = meta["phoneme"].to_numpy()

c_sel = ["aa", "sh", "dcl"]
n_per = 30
idx = np.concatenate([np.where(ph == c)[0][:n_per] for c in c_sel])
X_sub = np.ascontiguousarray(X[idx], dtype=np.float64)
y_sub = np.array([i for i in range(3) for _ in range(n_per)], dtype=np.int64)

tr_idx = np.concatenate([np.where(y_sub == i)[0][:24] for i in range(3)])
te_idx = np.concatenate([np.where(y_sub == i)[0][24:] for i in range(3)])
X_tr, y_tr = X_sub[tr_idx], y_sub[tr_idx]
X_te, y_te = X_sub[te_idx], y_sub[te_idx]

# kNN (k=1) classifier
clf_knn = shapelet_classifier_fit(X_tr, y_tr, max_candidates=200, seed=42,
                                   classifier="knn", k=1)
preds_knn = clf_knn.predict(X_te)
test_acc_knn  = np.mean(preds_knn == y_te)
per_cls_knn   = [np.mean(preds_knn[y_te == i] == y_te[y_te == i])
                 for i in range(3)]

# LDA classifier (linear discriminant)
clf_lda = shapelet_classifier_fit(X_tr, y_tr, max_candidates=200, seed=42,
                                   classifier="lda")
preds_lda = clf_lda.predict(X_te)
test_acc_lda  = np.mean(preds_lda == y_te)
per_cls_lda   = [np.mean(preds_lda[y_te == i] == y_te[y_te == i])
                 for i in range(3)]

palette = ["#3f51b5", "#6f42c1", "#198754"]
f, axes = fig(ncols=2, figsize=(9.0, 4.0))

# Per-class accuracy — kNN
axes[0].bar(c_sel, per_cls_knn, color=palette, width=0.5)
for k, v in enumerate(per_cls_knn):
    axes[0].text(k, v + 0.03, f"{v:.0%}", ha="center", fontsize=9)
axes[0].set(
    title=f"kNN (k=1) — overall test accuracy {test_acc_knn:.0%}"
          f"\n(train {clf_knn.train_accuracy:.0%}, {clf_knn.n_shapelets} shapelets)",
    ylabel="accuracy", ylim=(0, 1.18),
)

# Per-class accuracy — LDA
axes[1].bar(c_sel, per_cls_lda, color=palette, width=0.5)
for k, v in enumerate(per_cls_lda):
    axes[1].text(k, v + 0.03, f"{v:.0%}", ha="center", fontsize=9)
axes[1].set(
    title=f"LDA — overall test accuracy {test_acc_lda:.0%}"
          f"\n(train {clf_lda.train_accuracy:.0%}, {clf_lda.n_shapelets} shapelets)",
    ylabel="accuracy", ylim=(0, 1.18),
)

print(render(f))
print("FDARS_FENCE_OK")
image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/
FDARS_FENCE_OK

Both classifiers achieve useful separation on this small test set. LDA tends to generalise better on the shapelet feature space because the decision boundary across \(K\) shapelet distances is approximately linear — the class centroids in feature space are well separated. kNN (k=1) is more sensitive to individual outliers in the 18-curve test set. With only 24 training curves per class and max_candidates=200, the discovery is a fast approximation; full accuracy requires more data or an exhaustive search.

Concrete numbers

import numpy as np
from docs_data import load_phoneme
from fdars.shapelet import (discover_shapelets, shapelet_transform_fit,
                             shapelet_transform, shapelet_classifier_fit)

freq, X, meta = load_phoneme()
ph = meta["phoneme"].to_numpy()

c_sel = ["aa", "sh", "dcl"]
n_per = 30
idx = np.concatenate([np.where(ph == c)[0][:n_per] for c in c_sel])
X_sub = np.ascontiguousarray(X[idx], dtype=np.float64)
y_sub = np.array([i for i in range(3) for _ in range(n_per)], dtype=np.int64)

tr_idx = np.concatenate([np.where(y_sub == i)[0][:24] for i in range(3)])
te_idx = np.concatenate([np.where(y_sub == i)[0][24:] for i in range(3)])
X_tr, y_tr = X_sub[tr_idx], y_sub[tr_idx]
X_te, y_te = X_sub[te_idx], y_sub[te_idx]

disc  = discover_shapelets(X_tr, y_tr, max_candidates=200, seed=42)
stfit = shapelet_transform_fit(X_tr, y_tr, max_candidates=200, seed=42)
feat_tr = shapelet_transform(stfit, X_tr)
feat_te = shapelet_transform(stfit, X_te)

clf = shapelet_classifier_fit(X_tr, y_tr, max_candidates=200, seed=42,
                               classifier="knn", k=1)
preds    = clf.predict(X_te)
test_acc = np.mean(preds == y_te)

print(f"Discovery summary:  n_shapelets={disc['n_shapelets']},  quality='{disc['quality']}'")
print(f"Transform fit:      stfit.n_shapelets={stfit.n_shapelets},  n_train={stfit.n_train}")
print(f"Feature matrix:     train {feat_tr.shape},  test {feat_te.shape}")
print(f"Classifier (kNN):   train_accuracy={clf.train_accuracy:.3f},  test_accuracy={test_acc:.3f}")
print()
print("Per-class test accuracy (kNN k=1):")
for i, cls in enumerate(c_sel):
    mask = y_te == i
    cls_acc = np.mean(preds[mask] == y_te[mask])
    print(f"  {cls}: {cls_acc:.2f}  ({mask.sum()} curves)")
print("FDARS_FENCE_OK")

Discovery summary: n_shapelets=93, quality='info_gain' Transform fit: stfit.n_shapelets=93, n_train=72 Feature matrix: train (72, 93), test (18, 93) Classifier (kNN): train_accuracy=0.875, test_accuracy=0.722

Per-class test accuracy (kNN k=1): aa: 1.00 (6 curves) sh: 0.83 (6 curves) dcl: 0.33 (6 curves) FDARS_FENCE_OK

The discovery summary reports the number of shapelets retained after the information-gain filter and the quality criterion used. The transform fit stores the same shapelets internally and applies them to produce the (72, K) training feature matrix and (18, K) test feature matrix. Test accuracy in the 72%–78% range is expected here: with 30 curves per class and max_candidates=200, shapelet discovery finds an informative but incomplete set of discriminative subsequences. The accuracy reported above reflects the actual run — exact values may vary slightly with the data ordering and seed.

Scaling up to 5 classes

All five phoneme classes (20 curves/class, 100 total) run through the same pipeline in under two seconds with max_candidates=200. The five-class problem is harder — classes ao and iy share spectral territory with aa — so per-class accuracy drops for the harder pairs. Using more training curves per class or a larger max_candidates budget raises accuracy; the max_candidates=0 exhaustive mode searches all possible subsequences but takes considerably longer. For production classification, tune max_candidates and the classifier type ("knn" vs "lda") together on a held-out validation set.

Parameters

Function Key parameters Description
discover_shapelets(data, labels, max_candidates, seed) max_candidates, seed, min_length, max_length, quality Runs shapelet discovery; returns summary dict {n_shapelets, quality}. Does not return raw shapelet arrays.
shapelet_transform_fit(data, labels, max_candidates, seed) max_candidates, seed, min_length, max_length Fits and stores shapelets; returns PyShapeletFit handle with .n_shapelets and .n_train.
shapelet_transform(fit, new_data) — Transforms new_data into (n, K) feature matrix of minimum-distance scores.
shapelet_classifier_fit(data, labels, max_candidates, seed, classifier, k) classifier ("knn" or "lda"), k (kNN neighbours), ncomp (optional PCA pre-reduction) End-to-end: discover + transform + fit classifier; returns handle with .train_accuracy, .predict(new_data).

See also

  • Shapelets — method page: shapelet discovery algorithm, GAK kernel, parameter guidance for max_candidates, min_length, quality
  • Phoneme — Shape Classification — same dataset, elastic (Fisher–Rao) shape analysis instead of shapelets; contrasts amplitude vs elastic distance for spectral data
  • Clustering functional data — downstream use of shapelet feature matrices for unsupervised partitioning

References

  • Ye, L. and Keogh, E. (2009). Time series shapelets: a new primitive for data mining. Proceedings of the 15th ACM SIGKDD, 947–956.
  • Hastie, T., Tibshirani, R. and Friedman, J. (2009). The Elements of Statistical Learning, 2nd ed. Springer. (Phoneme dataset, Section 5.2.)
  • Lines, J., Davis, L.M., Hills, J. and Bagnall, A. (2012). A shapelet transform for time series classification. Proceedings of the 18th ACM SIGKDD, 289–297.