File size: 12,373 Bytes
54183d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f110a8c
54183d5
 
 
f110a8c
54183d5
f110a8c
 
 
 
 
54183d5
 
 
 
 
f110a8c
 
 
 
 
 
 
 
 
 
 
 
 
54183d5
f110a8c
 
 
 
 
 
 
 
 
c1ea798
f110a8c
 
 
 
 
 
 
54183d5
 
f110a8c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ca5c15
f110a8c
 
 
 
 
 
 
54183d5
 
1ca5c15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54183d5
 
 
 
f110a8c
54183d5
 
 
f110a8c
54183d5
 
 
 
 
f110a8c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54183d5
f110a8c
 
 
 
 
 
 
 
 
 
 
 
 
 
54183d5
 
 
 
 
 
 
f110a8c
54183d5
 
 
f110a8c
54183d5
 
 
 
 
 
 
 
f110a8c
 
 
 
 
 
54183d5
 
f110a8c
 
1ca5c15
 
f110a8c
 
 
54183d5
 
 
 
 
 
 
 
 
f110a8c
 
54183d5
 
b3e10d5
 
54183d5
 
f110a8c
 
54183d5
 
 
 
 
 
 
 
f110a8c
54183d5
 
 
 
 
 
f110a8c
54183d5
 
 
 
 
 
 
 
f110a8c
 
54183d5
 
 
 
 
 
f110a8c
54183d5
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
"""Static data for the About tab β€” algorithms, metrics, datasets.

Kept separate from app.py for clarity and maintainability.
Algorithms are ordered: CPD/Distribution-based, CPD/Subsequence-based,
SD/Integrated, SD/Decomposed.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Sequence

# ---------------------------------------------------------------------------
# Algorithms
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class AlgoEntry:
    """One row of the algorithm table."""

    name: str
    task: str  # "CPD" or "SD"
    family: str  # Distribution-based, Subsequence-based, Integrated, Decomposed
    complexity: str  # Big-O complexity (math notation, no $ wrappers)
    license: str  # SPDX-like short id, or "β€”" if unknown
    source: str  # upstream project / author


# Ordered: CPD Distribution-based β†’ CPD Subsequence-based β†’ SD Integrated β†’ SD Decomposed
ALGO_ENTRIES: Sequence[AlgoEntry] = (
    # ── CPD Β· Distribution-based ──────────────────────────────────────────
    AlgoEntry("AMOC", "CPD", "Distribution-based", "O(dn)", "BSD-2", "ruptures"),
    AlgoEntry("BinSeg", "CPD", "Distribution-based", "O(dn\\log n)", "BSD-2", "ruptures"),
    AlgoEntry("BottomUp", "CPD", "Distribution-based", "O(dn)", "BSD-2", "ruptures"),
    AlgoEntry("BOCD", "CPD", "Distribution-based", "O(dn^2)", "Apache-2.0", "hildensia"),
    AlgoEntry("DynP", "CPD", "Distribution-based", "O(n_{cp}\\,dn^2)", "BSD-2", "ruptures"),
    AlgoEntry("EAgglo", "CPD", "Distribution-based", "O(dn^2)", "BSD-3", "aeon"),
    AlgoEntry("GreedyGaussian", "CPD", "Distribution-based", "O(n_{cp}^2 d^3 n)", "BSD-3", "aeon"),
    AlgoEntry("ICID", "CPD", "Distribution-based", "O(dn)", "GPLv3", "IsolationKernel"),
    AlgoEntry("KCPD", "CPD", "Distribution-based", "O(n_{cp}\\,dn^2)", "BSD-2", "ruptures"),
    AlgoEntry("Pelt", "CPD", "Distribution-based", "O(dn)", "BSD-2", "ruptures"),
    AlgoEntry("Prophet", "CPD", "Distribution-based", "O(n_{cp}\\,dn)", "MIT", "Facebook"),
    AlgoEntry("Window", "CPD", "Distribution-based", "O(dn)", "BSD-2", "ruptures"),
    AlgoEntry("Beast", "CPD", "Distribution-based", "-", "MIT", "Rbeast"),
    # ── CPD Β· Subsequence-based ───────────────────────────────────────────
    AlgoEntry("CLaSP", "CPD", "Subsequence-based", "O(n_{cp}\\,dn^2)", "BSD-3", "claspy"),
    AlgoEntry("ESPRESSO", "CPD", "Subsequence-based", "O(dn^2 + n_{cp}\\,n)", "β€”", "cruiseresearchgroup"),
    AlgoEntry("FLUSS", "CPD", "Subsequence-based", "O(dn^2 + n_{cp}\\,n)", "BSD-3", "aeon"),
    AlgoEntry("InformationGain", "CPD", "Subsequence-based", "O(n_{cp}\\,dn^2)", "BSD-3", "aeon"),
    AlgoEntry("TGLAD", "CPD", "Subsequence-based", "O(d^3 n)", "Non-Commercial", "Harshs27"),
    AlgoEntry("Tire", "CPD", "Subsequence-based", "O(dn)", "β€”", "KU Leuven"),
    AlgoEntry("TSCP2", "CPD", "Subsequence-based", "O(dn)", "β€”", "cruiseresearchgroup"),
    # ── SD Β· Integrated ───────────────────────────────────────────────────
    AlgoEntry("AutoPlait", "SD", "Integrated", "O(dn)", "β€”", "β€”"),
    AlgoEntry("HdpHsmm", "SD", "Integrated", "O(dn + n_s \\min(n, 2000)^2)", "β€”", "mattjj/pyhsmm (reimpl.)"),
    AlgoEntry("Hidalgo", "SD", "Integrated", "O(dn^2)", "BSD-3", "aeon"),
    AlgoEntry("HMM", "SD", "Integrated", "-", "BSD-3", "hmmlearn"),
    # ── SD Β· Decomposed ───────────────────────────────────────────────────
    AlgoEntry("TICC", "SD", "Decomposed", "O(n_s(dn + (dl)^3))", "BSD-2", "davidhallac"),
    AlgoEntry("Time2State", "SD", "Decomposed", "O(dn)", "MIT", "Lab-ANT"),
    AlgoEntry("E2USD", "SD", "Decomposed", "O(dn\\log l)", "β€”", "AI4CTS"),
    AlgoEntry("Clap", "SD", "Decomposed", "O(n_{cp}\\,n(nd + n_{cp}\\log n_{cp}))", "BSD-3", "claspy"),
)

# Quick lookup by name (case-insensitive)
ALGO_BY_NAME: dict[str, AlgoEntry] = {a.name.lower(): a for a in ALGO_ENTRIES}


def get_algo_entry(name: str) -> AlgoEntry | None:
    """Lookup an algorithm entry by name (case-insensitive)."""
    return ALGO_BY_NAME.get(name.lower())


# ---------------------------------------------------------------------------
# Complexity classification (for color-coding in the UI)
# ---------------------------------------------------------------------------


def classify_complexity(complexity: str) -> str:
    """Categorize a complexity string into a speed tier.

    Returns one of: ``linear``, ``log_linear``, ``quadratic``, ``cubic``.
    """
    s = complexity.lower().replace(" ", "")
    if "n^3" in s or "nΒ³" in s:
        return "cubic"
    if "n^2" in s or "nΒ²" in s:
        return "quadratic"
    if "logn" in s or "log(n)" in s or "\\logn" in s:
        return "log_linear"
    return "linear"


COMPLEXITY_COLORS = {
    "linear": "#1e7d32",  # green
    "log_linear": "#1565c0",  # blue
    "quadratic": "#ef6c00",  # orange
    "cubic": "#c62828",  # red
}

COMPLEXITY_LABELS = {
    "linear": "linear",
    "log_linear": "log-linear",
    "quadratic": "quadratic",
    "cubic": "cubic",
}


# ---------------------------------------------------------------------------
# Variable glossary (for tooltips)
# ---------------------------------------------------------------------------

COMPLEXITY_GLOSSARY = {
    "n": "number of points in the time series",
    "d": "number of dimensions (channels)",
    "l": "window size",
    "n_cp": "number of change points",
    "n_s": "number of distinct states",
}

COMPLEXITY_GLOSSARY_MD = "**Complexity notation:** " + " Β· ".join(
    f"`{k}` = {v}" for k, v in COMPLEXITY_GLOSSARY.items()
)


def format_complexity_plain(s: str) -> str:
    """Render a LaTeX-ish complexity string as plain text.

    The complexity strings in :data:`ALGO_ENTRIES` use LaTeX fragments such as
    ``\\log``, ``n^2`` and ``n_{cp}``. Gradio's Markdown renderer on the
    Hugging Face Space runtime does not consistently process ``$...$`` math,
    so we convert these strings into a readable Unicode form (e.g.
    ``O(dn log l)``, ``O(n_cp dnΒ²)``) instead of relying on KaTeX.
    """
    import re

    if not s:
        return s
    out = s
    out = out.replace("\\log", " log").replace("\\cdot", "Β·").replace("\\,", " ")
    out = (
        out.replace("n^2", "nΒ²")
        .replace("n^3", "nΒ³")
        .replace("d^2", "dΒ²")
        .replace("d^3", "dΒ³")
        .replace("n_s^2", "n_sΒ²")
        .replace("n_{cp}^2", "n_cpΒ²")
    )
    # Subscripts: n_{cp} -> n_cp
    out = re.sub(r"_\{([^}]+)\}", r"_\1", out)
    # Strip any leftover backslashes
    out = out.replace("\\", "")
    return out


# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class MetricEntry:
    name: str
    task: str  # "CPD" or "SD"
    description: str


METRIC_ENTRIES: Sequence[MetricEntry] = (
    # CPD
    MetricEntry(
        "F1 Score", "CPD", "Classic precision / recall / F1 with a hard margin tolerance around each true change point"
    ),
    MetricEntry(
        "Gaussian F1",
        "CPD",
        "Gaussian-weighted soft F1 β€” predictions are rewarded via a Gaussian kernel "
        "that decays with distance, no hard margin",
    ),
    MetricEntry("Covering", "CPD", "Segment-wise Intersection over Union (IoU) weighted by segment duration"),
    MetricEntry(
        "Bidirectional Covering",
        "CPD",
        "Evaluates coverage in both directions (truth→pred and pred→truth) aggregated by harmonic mean",
    ),
    MetricEntry(
        "Hausdorff Distance",
        "CPD",
        "Maximum over all true CPs of the distance to the nearest predicted CP (and vice-versa)",
    ),
    # SD
    MetricEntry(
        "Adjusted Rand Index", "SD", "Chance-adjusted agreement between true and predicted label partitions (sklearn)"
    ),
    MetricEntry(
        "Normalized Mutual Information", "SD", "Information-theoretic agreement normalised to [0, 1] (sklearn)"
    ),
    MetricEntry("Adjusted Mutual Information", "SD", "Chance-adjusted variant of NMI (sklearn)"),
    MetricEntry("Weighted ARI", "SD", "ARI variant that gives more weight to samples near segment boundaries"),
    MetricEntry("Weighted NMI", "SD", "NMI variant that gives more weight to samples near segment boundaries"),
    MetricEntry(
        "State Matching Score",
        "SD",
        "Hungarian matching of predicted to true states with fine-grained error classification",
    ),
)


# ---------------------------------------------------------------------------
# Datasets
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class DatasetEntry:
    name: str
    dtype: str  # "Generated" or "Real-world"
    description: str
    dimensions: str
    segments: str
    source: str


DATASET_ENTRIES: Sequence[DatasetEntry] = (
    DatasetEntry(
        "Sample dataset",
        "Real-world",
        "A single labelled trial from CMU MoCap 86 (humeral & femoral angles), served as the built-in example",
        "4",
        "3–8",
        "[CMU MoCap](https://mocap.cs.cmu.edu/)",
    ),
    DatasetEntry(
        "Synthetic",
        "Generated",
        "Configurable generator with 7 intra-segment patterns "
        "(Gaussian processes, sinusoidal, constant, step, stairs, linear, mixed), noise control, recurring states",
        "1–8",
        "2–12",
        "tsseg",
    ),
)


# ---------------------------------------------------------------------------
# Markdown builders
# ---------------------------------------------------------------------------


def build_algo_table(runtime_detectors: dict | None = None) -> str:
    """Return a Markdown table of algorithms (no group separator rows)."""
    rows: list[str] = []
    for a in ALGO_ENTRIES:
        complexity = format_complexity_plain(a.complexity)
        rows.append(f"| {a.name} | {a.task} | {a.family} | `{complexity}` | {a.license} | {a.source} |")

    header = (
        "| Algorithm | Task | Family | Complexity | License | Source |\n"
        "|-----------|------|--------|------------|---------|--------|\n"
    )
    return header + "\n".join(rows)


def build_metric_table() -> str:
    rows: list[str] = []
    for m in METRIC_ENTRIES:
        rows.append(f"| {m.name} | {m.task} | {m.description} |")
    header = "| Metric | Task | Description |\n|--------|------|-------------|\n"
    return header + "\n".join(rows)


def build_dataset_table() -> str:
    rows: list[str] = []
    for d in DATASET_ENTRIES:
        rows.append(f"| {d.name} | {d.dtype} | {d.description} | {d.dimensions} | {d.segments} | {d.source} |")
    header = (
        "| Dataset | Type | Description | Dimensions | Segments | Source |\n"
        "|---------|------|-------------|------------|----------|--------|\n"
    )
    return header + "\n".join(rows)


DISCLAIMER_MD = (
    "> **⚠️ Disclaimer:** Some algorithms were adapted from research paper artifacts "
    'published without an explicit license (marked "β€”" above). These are provided '
    "for **research and educational purposes only**. If you are the author of one of "
    "these works and wish to specify licensing terms, please "
    "[open an issue](https://github.com/fchavelli/tsseg/issues)."
)

MOCAP_NOTE_MD = (
    "> The sample dataset uses data from the **CMU Graphics Lab Motion Capture Database** "
    "(NSF EIA-0196217). Labels were sourced from Time2State (Wang et al., 2023). "
    "Only the first rotation axis of left/right humerus and femur is kept, following "
    "the protocol of AutoPlait (Matsubara et al., 2014)."
)

LINKS_MD = """- [tsseg Documentation](https://fchavelli.github.io/tsseg/)
- [GitHub Repository](https://github.com/fchavelli/tsseg)
- [License: AGPLv3](https://github.com/fchavelli/tsseg/blob/main/LICENSE)

Built with [Gradio](https://gradio.app) Β· Powered by [tsseg](https://github.com/fchavelli/tsseg)"""