You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@otava.apache.org by as...@apache.org on 2026/06/08 02:30:16 UTC
(otava-playground) branch params-per-algorithm-flavour created (now 00c8f64)
This is an automated email from the ASF dual-hosted git repository.
Gerrrr pushed a change to branch params-per-algorithm-flavour
in repository https://gitbox.apache.org/repos/asf/otava-playground.git
at 00c8f64 Use independent parameters for Otava algorithms
This branch includes the following new commits:
new 00c8f64 Use independent parameters for Otava algorithms
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(otava-playground) 01/01: Use independent parameters for Otava algorithms
Posted by as...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
Gerrrr pushed a commit to branch params-per-algorithm-flavour
in repository https://gitbox.apache.org/repos/asf/otava-playground.git
commit 00c8f64d30561ff5a775de126458c6bfcdac91d8
Author: Alex Sorokoumov <al...@gmail.com>
AuthorDate: Sun Jun 7 19:27:45 2026 -0700
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.
---
src/otava_test_data/tests/test_compare_api.py | 50 +++++++++++++++++
src/otava_test_data/web/main.py | 48 ++++++++++++++---
src/otava_test_data/web/static/css/style.css | 52 +++++++++++++++++-
src/otava_test_data/web/static/js/app.js | 70 ++++++++++++++++--------
src/otava_test_data/web/templates/index.html | 77 ++++++++++++++++++---------
5 files changed, 241 insertions(+), 56 deletions(-)
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 @@ def test_analyze_accepts_otava_algorithm():
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 @@ async def analyze_with_otava(
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 @@ class DetectRequest(BaseModel):
@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 @@ async def detect_change_points(
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 @@ def _run_algorithm(name: str, data, window_len: int, max_pvalue: float,
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 @@ async def compare_algorithms(
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 @@ async def compare_algorithms(
"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 @@ footer a:hover {
}
/* Wider input for p-value to accommodate small values like 0.00001 */
-#max-pvalue-input {
+.max-pvalue-input {
width: 90px;
}
@@ -1990,11 +1990,21 @@ body[data-mode="dataset"] .hide-in-dataset {
.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 @@ body[data-mode="dataset"] .hide-in-dataset {
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 @@ const datasetResultsBody = document.getElementById('dataset-results-body');
// 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 @@ function primaryOtavaAlgorithm() {
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 @@ function setupEventListeners() {
// 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 @@ async function generateData() {
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 @@ function updateComparisonTables(data) {
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 @@ async function showAllPatterns() {
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 @@ async function runDatasetAnalysis() {
}
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 @@ async function computeAndDisplayMixedData() {
// 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="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="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="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="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="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>