Use independent parameters for Otava algorithms In https://github.com/apache/otava/pull/154, Henrik mentioned that using the same P-value for all 3 algorithms is not a fair comparison. > The deterministic variant finds much less change points for the > same p-values. It is closer to original edivisive in behavior than to > the hunter variant. To some extent this is a matter of perspective too: Who's to say we should compare them with constant p-values This patch allows setting P-values independently for all 3 algorithms. It also "boxes" window size to the split algorithm, which may be a more intuitive UX for new users.
diff --git a/src/otava_test_data/tests/test_compare_api.py b/src/otava_test_data/tests/test_compare_api.py index f818118..ff84289 100644 --- a/src/otava_test_data/tests/test_compare_api.py +++ b/src/otava_test_data/tests/test_compare_api.py
@@ -159,3 +159,53 @@ assert "otava" in body assert "error" not in body["otava"] assert body["otava"]["parameters"]["algorithm"] == "orig" + + +def test_compare_applies_per_algorithm_params(): + """`algorithm_params` should override the query-string defaults per algorithm, + and missing per-algorithm keys should fall back to those defaults.""" + series = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0] + r = client.post( + "/api/compare?max_pvalue=0.5&window_len=40", + json={ + "data": series, + "algorithms": ["split", "orig"], + "algorithm_params": { + "split": {"window_len": 20, "max_pvalue": 0.001}, + "orig": {"max_pvalue": 0.01}, + }, + }, + ) + assert r.status_code == 200 + body = r.json() + # The endpoint echoes back the resolved per-algorithm params so the UI can + # display "what was actually run." + assert body["algorithm_params"]["split"] == { + "window_len": 20, "max_pvalue": 0.001, "min_magnitude": 0.0, + } + # `orig` only overrode max_pvalue; window_len falls back to the query default. + assert body["algorithm_params"]["orig"] == { + "window_len": 40, "max_pvalue": 0.01, "min_magnitude": 0.0, + } + + +def test_compare_without_per_algorithm_params_uses_query_defaults(): + """`algorithm_params` is optional — falling back to query defaults must still work.""" + series = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0] + r = client.post( + "/api/compare?max_pvalue=0.05", + json={"data": series, "algorithms": ["split"]}, + ) + assert r.status_code == 200 + assert r.json()["algorithm_params"]["split"]["max_pvalue"] == 0.05 + + +def test_detect_accepts_otava_algorithm(): + """`/api/detect` (used by mix mode) should respect the chosen algorithm.""" + series = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0] + r = client.post( + "/api/detect?otava_algorithm=orig&max_pvalue=0.05", + json={"data": series}, + ) + assert r.status_code == 200 + assert r.json()["parameters"]["algorithm"] == "orig"
diff --git a/src/otava_test_data/web/main.py b/src/otava_test_data/web/main.py index 4edbb44..aafc981 100644 --- a/src/otava_test_data/web/main.py +++ b/src/otava_test_data/web/main.py
@@ -1568,7 +1568,7 @@ length: int = Query(default=200, ge=10, le=2000), seed: int = Query(default=42), otava_algorithm: AlgorithmName = Query(default="split", description="Otava algorithm to run"), # noqa: B008 - window_len: int = Query(default=_OTAVA_DEFAULTS.window_len, ge=5, le=100, description="Otava window length"), + window_len: int = Query(default=_OTAVA_DEFAULTS.window_len, ge=5, le=100000, description="Otava window length"), max_pvalue: float = Query(default=_OTAVA_DEFAULTS.max_pvalue, ge=0.0, le=1.0, description="Otava max p-value"), min_magnitude: float = Query(default=_OTAVA_DEFAULTS.min_magnitude, ge=0, description="Minimum change magnitude"), tolerance: int = Query(default=5, ge=0, le=50, description="Accuracy tolerance"), @@ -1675,6 +1675,7 @@ @app.post("/api/detect") async def detect_change_points( request: DetectRequest, + otava_algorithm: AlgorithmName = Query(default="split", description="Otava algorithm to run"), # noqa: B008 window_len: int = Query(default=_OTAVA_DEFAULTS.window_len, ge=5, le=100000, description="Otava window length"), max_pvalue: float = Query(default=_OTAVA_DEFAULTS.max_pvalue, ge=0.0, le=1.0, description="Otava max p-value"), min_magnitude: float = Query(default=_OTAVA_DEFAULTS.min_magnitude, ge=0, description="Minimum change magnitude"), @@ -1693,6 +1694,7 @@ window_len=window_len, max_pvalue=max_pvalue, min_magnitude=min_magnitude, + algorithm=otava_algorithm, ) return result except Exception as e: @@ -1749,10 +1751,20 @@ return out +class AlgorithmParams(BaseModel): + """Optional per-algorithm parameter overrides for /api/compare.""" + window_len: int | None = None + max_pvalue: float | None = None + min_magnitude: float | None = None + + class CompareRequest(BaseModel): """Request body for /api/compare.""" data: list[float] algorithms: list[AlgorithmName] | None = None # default: all available + # Per-algorithm overrides; missing fields fall back to the request's query-string + # defaults (window_len / max_pvalue / min_magnitude). + algorithm_params: dict[AlgorithmName, AlgorithmParams] | None = None @app.get("/api/datasets") @@ -1783,18 +1795,39 @@ max_pvalue: float = Query(default=_OTAVA_DEFAULTS.max_pvalue, ge=0.0, le=1.0), min_magnitude: float = Query(default=_OTAVA_DEFAULTS.min_magnitude, ge=0), ): - """Run multiple change-point algorithms on the same series and return all results.""" + """Run multiple change-point algorithms on the same series and return all results. + + Each algorithm can have its own parameter overrides via `algorithm_params`; + missing keys fall back to the query-string defaults. + """ if not request.data: return JSONResponse(status_code=400, content={"error": "No data provided"}) if not OTAVA_AVAILABLE: return JSONResponse(status_code=503, content={"error": "apache-otava not installed"}) algorithms = request.algorithms or [n for n, a in ALGORITHMS.items() if a["available"]] - # run_otava_analysis handles float coercion internally. - results = { - name: _run_algorithm(name, request.data, window_len, max_pvalue, min_magnitude) - for name in algorithms - } + overrides = request.algorithm_params or {} + + def params_for(name: str) -> dict[str, float]: + override = overrides.get(name) + if override is None: + return {"window_len": window_len, "max_pvalue": max_pvalue, + "min_magnitude": min_magnitude} + return { + "window_len": override.window_len if override.window_len is not None else window_len, + "max_pvalue": override.max_pvalue if override.max_pvalue is not None else max_pvalue, + "min_magnitude": ( + override.min_magnitude if override.min_magnitude is not None else min_magnitude + ), + } + + results = {} + per_algo_params = {} + for name in algorithms: + p = params_for(name) + per_algo_params[name] = p + results[name] = _run_algorithm(name, request.data, **p) + return { "results": results, "parameters": { @@ -1802,6 +1835,7 @@ "max_pvalue": max_pvalue, "min_magnitude": min_magnitude, }, + "algorithm_params": per_algo_params, }
diff --git a/src/otava_test_data/web/static/css/style.css b/src/otava_test_data/web/static/css/style.css index dc9c65b..b0a70b1 100644 --- a/src/otava_test_data/web/static/css/style.css +++ b/src/otava_test_data/web/static/css/style.css
@@ -1097,7 +1097,7 @@ } /* Wider input for p-value to accommodate small values like 0.00001 */ -#max-pvalue-input { +.max-pvalue-input { width: 90px; } @@ -1990,11 +1990,21 @@ .otava-algos { display: flex; flex-direction: column; - gap: 4px; + gap: 10px; margin: 4px 0 12px; font-size: 13px; } +.otava-algo { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 8px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 4px; + background: rgba(248, 250, 252, 0.5); +} + .otava-algo-row { display: flex; align-items: center; @@ -2015,3 +2025,41 @@ color: #64748b; font-size: 11px; } + +/* Per-algorithm parameter rows nested inside .otava-algo */ +.otava-algo .algo-params { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-left: 24px; +} + +.otava-algo .algo-params .control-group { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.5rem; +} + +.otava-algo .algo-params .control-group label { + font-size: 0.75rem; + white-space: nowrap; + display: inline-block; + width: 100px; + text-align: left; +} + +.otava-algo .algo-params .control-group input { + width: 70px; + padding: 0.25rem 0.4rem; + font-size: 0.85rem; +} + +.otava-algo .algo-params .max-pvalue-input { + width: 90px; +} + +/* Dim parameter inputs when the algorithm checkbox is unchecked. */ +.otava-algo:has(.otava-algo-checkbox:not(:checked)) .algo-params { + opacity: 0.45; +}
diff --git a/src/otava_test_data/web/static/js/app.js b/src/otava_test_data/web/static/js/app.js index f11b691..2108e16 100644 --- a/src/otava_test_data/web/static/js/app.js +++ b/src/otava_test_data/web/static/js/app.js
@@ -68,8 +68,7 @@ // DOM Elements - Otava Controls const otavaAlgoCheckboxes = document.querySelectorAll('.otava-algo-checkbox'); -const windowLenInput = document.getElementById('window-len-input'); -const maxPvalueInput = document.getElementById('max-pvalue-input'); +const otavaAlgoParamInputs = document.querySelectorAll('.otava-algos [data-algo-param]'); const yMinInput = document.getElementById('y-min-input'); const yMaxInput = document.getElementById('y-max-input'); const yMinSlider = document.getElementById('y-min-slider'); @@ -128,6 +127,30 @@ return enabled[0] || 'split'; } +/** Read the per-algorithm parameter inputs and return a flat dict suitable + * for query-string serialization (e.g. {window_len, max_pvalue}). Window + * length only exists for 'split'; missing inputs are simply absent. + * + * Reads the input values regardless of whether the algorithm's checkbox is + * checked — callers (e.g. generateData with the primary algo) may need the + * params even when the algo isn't "enabled" in the multi-select sense. */ +function getOtavaParamsForAlgo(algoName) { + const params = {}; + document + .querySelectorAll(`.otava-algo[data-algo="${algoName}"] [data-algo-param]`) + .forEach(input => { params[input.dataset.algoParam] = input.value; }); + return params; +} + +/** Per-algorithm params for every enabled algorithm — used by /api/compare. */ +function getOtavaParamsByAlgo() { + const out = {}; + for (const name of getEnabledOtavaAlgorithms()) { + out[name] = getOtavaParamsForAlgo(name); + } + return out; +} + // DOM Elements - Actions const generateBtn = document.getElementById('generate-btn'); const showAllBtn = document.getElementById('show-all-btn'); @@ -769,8 +792,7 @@ // Otava controls otavaAlgoCheckboxes.forEach(cb => cb.addEventListener('change', refreshDisplay)); - windowLenInput.addEventListener('change', refreshDisplay); - maxPvalueInput.addEventListener('change', refreshDisplay); + otavaAlgoParamInputs.forEach(input => input.addEventListener('change', refreshDisplay)); // Y-Axis Min slider with bounds setupSliderWithBounds(yMinSlider, yMinInput, yMinBoundMin, yMinBoundMax, refreshDisplay); @@ -1233,15 +1255,18 @@ const seed = seedInput.value; const runOtava = isOtavaEnabled(); - // Build query params + // Single-pattern mode only renders the primary algorithm's detections; + // the backend reads only that algorithm's params, so send just those. + const algo = primaryOtavaAlgorithm(); + const algoParams = getOtavaParamsForAlgo(algo); + const params = new URLSearchParams({ length, seed, run_otava: runOtava, - otava_algorithm: primaryOtavaAlgorithm(), - window_len: windowLenInput.value, - max_pvalue: maxPvalueInput.value, + otava_algorithm: algo, tolerance: DEFAULT_TOLERANCE, + ...algoParams, }); // Add dynamic params @@ -2055,9 +2080,10 @@ async function showAllPatterns() { const length = lengthInput.value; const seed = seedInput.value; - const windowLen = windowLenInput.value; - const maxPvalue = maxPvalueInput.value; const tolerance = DEFAULT_TOLERANCE; + // /api/analyze runs a single algorithm; "Show All" pins it to the primary. + const algo = primaryOtavaAlgorithm(); + const algoParams = getOtavaParamsForAlgo(algo); try { document.body.classList.add('loading'); @@ -2071,9 +2097,9 @@ const params = new URLSearchParams({ length, seed, - window_len: windowLen, - max_pvalue: maxPvalue, tolerance, + otava_algorithm: algo, + ...algoParams, }); const response = await fetch(`/api/analyze/${name}?${params}`); @@ -2512,18 +2538,18 @@ } const algos = getEnabledOtavaAlgorithms(); - const params = new URLSearchParams({ - window_len: windowLenInput.value, - max_pvalue: maxPvalueInput.value, - min_magnitude: '0', - }); + const algoParams = getOtavaParamsByAlgo(); try { document.body.classList.add('loading'); - const r = await fetch(`/api/compare?${params}`, { + const r = await fetch(`/api/compare`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ data: series, algorithms: algos }), + body: JSON.stringify({ + data: series, + algorithms: algos, + algorithm_params: algoParams, + }), signal: ctrl.signal, }); if (!r.ok) { @@ -3076,9 +3102,11 @@ // Run Otava analysis on mixed data if enabled if (isOtavaEnabled()) { try { + // The mix chart renders a single Otava result; pick the primary algo. + const algo = primaryOtavaAlgorithm(); const params = new URLSearchParams({ - window_len: windowLenInput.value, - max_pvalue: maxPvalueInput.value, + otava_algorithm: algo, + ...getOtavaParamsForAlgo(algo), }); const response = await fetch(`/api/detect?${params}`, { method: 'POST',
diff --git a/src/otava_test_data/web/templates/index.html b/src/otava_test_data/web/templates/index.html index 0868d92..a44e724 100644 --- a/src/otava_test_data/web/templates/index.html +++ b/src/otava_test_data/web/templates/index.html
@@ -148,34 +148,59 @@ </div> <div class="otava-algos" id="otava-algos"> - <label class="otava-algo-row"> - <input type="checkbox" class="otava-algo-checkbox" - data-algo="split" id="otava-algo-split" checked> - split-edivisive <span class="algo-hint">(default)</span> - </label> - <label class="otava-algo-row"> - <input type="checkbox" class="otava-algo-checkbox" - data-algo="orig" id="otava-algo-orig"> - orig-edivisive <span class="algo-hint">(permutation test)</span> - </label> - <label class="otava-algo-row"> - <input type="checkbox" class="otava-algo-checkbox" - data-algo="deterministic" id="otava-algo-deterministic"> - deterministic-edivisive - <span class="algo-hint">(<a href="https://github.com/apache/otava/pull/154" target="_blank">PR #154</a>)</span> - </label> - </div> - - <div class="panel-fields"> - <div class="control-group"> - <label for="window-len-input" class="param-label">Window Length</label> - <input type="number" id="window-len-input" value="{{ otava_defaults.window_len }}" min="5" max="100000" step="5"> + <div class="otava-algo" data-algo="split"> + <label class="otava-algo-row"> + <input type="checkbox" class="otava-algo-checkbox" + data-algo="split" id="otava-algo-split" checked> + split-edivisive <span class="algo-hint">(default)</span> + </label> + <div class="algo-params"> + <div class="control-group"> + <label for="window-len-split" class="param-label">Window Length</label> + <input type="number" id="window-len-split" data-algo-param="window_len" data-algo="split" value="{{ otava_defaults.window_len }}" min="5" max="100000" step="5"> + </div> + <div class="control-group"> + <div class="input-with-help"> + <label for="max-pvalue-split" class="param-label">Max P-Value</label> + <input type="number" id="max-pvalue-split" class="max-pvalue-input" data-algo-param="max_pvalue" data-algo="split" value="{{ otava_defaults.max_pvalue }}" min="0" max="1" step="0.001"> + <button type="button" class="param-help-btn" data-tooltip="Significance threshold for detecting changes. Lower values mean stricter detection (fewer false positives but may miss subtle changes). 0.05 is standard; use 0.00001 for very high confidence.">?</button> + </div> + </div> + </div> </div> - <div class="control-group"> - <div class="input-with-help"> - <label for="max-pvalue-input" class="param-label">Max P-Value</label> - <input type="number" id="max-pvalue-input" value="{{ otava_defaults.max_pvalue }}" step="0.001"> - <button type="button" class="param-help-btn" data-tooltip="Significance threshold for detecting changes. Lower values mean stricter detection (fewer false positives but may miss subtle changes). 0.05 is standard; use 0.00001 for very high confidence.">?</button> + + <div class="otava-algo" data-algo="orig"> + <label class="otava-algo-row"> + <input type="checkbox" class="otava-algo-checkbox" + data-algo="orig" id="otava-algo-orig"> + orig-edivisive <span class="algo-hint">(permutation test)</span> + </label> + <div class="algo-params"> + <div class="control-group"> + <div class="input-with-help"> + <label for="max-pvalue-orig" class="param-label">Max P-Value</label> + <input type="number" id="max-pvalue-orig" class="max-pvalue-input" data-algo-param="max_pvalue" data-algo="orig" value="{{ otava_defaults.max_pvalue }}" min="0" max="1" step="0.001"> + <button type="button" class="param-help-btn" data-tooltip="Significance threshold for detecting changes. Lower values mean stricter detection (fewer false positives but may miss subtle changes). 0.05 is standard; use 0.00001 for very high confidence.">?</button> + </div> + </div> + </div> + </div> + + <div class="otava-algo" data-algo="deterministic"> + <label class="otava-algo-row"> + <input type="checkbox" class="otava-algo-checkbox" + data-algo="deterministic" id="otava-algo-deterministic"> + deterministic-edivisive + <span class="algo-hint">(<a href="https://github.com/apache/otava/pull/154" target="_blank">PR #154</a>)</span> + </label> + <div class="algo-params"> + <div class="control-group"> + <div class="input-with-help"> + <label for="max-pvalue-deterministic" class="param-label">Max P-Value</label> + <input type="number" id="max-pvalue-deterministic" class="max-pvalue-input" data-algo-param="max_pvalue" data-algo="deterministic" value="{{ otava_defaults.max_pvalue }}" min="0" max="1" step="0.001"> + <button type="button" class="param-help-btn" data-tooltip="Significance threshold for detecting changes. Lower values mean stricter detection (fewer false positives but may miss subtle changes). 0.05 is standard; use 0.00001 for very high confidence.">?</button> + </div> + </div> </div> </div> </div>