eventscore-explorer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
@@ -0,0 +1,54 @@
1
+ import argparse
2
+ import json
3
+ from dataclasses import asdict
4
+ from pathlib import Path
5
+ from .core import load_csv, evaluate
6
+
7
+ def report(truth,predictions,tolerances):
8
+ payload=json.dumps({'truth':[asdict(x) for x in truth], 'predictions':[asdict(x) for x in predictions],
9
+ 'results':[evaluate(truth,predictions,x) for x in tolerances]},ensure_ascii=True).replace('<','\\u003c')
10
+ return TEMPLATE.replace('__DATA__',payload)
11
+
12
+ def main(argv=None):
13
+ p=argparse.ArgumentParser(description='Explain one-to-one event matching; CSV times in seconds.')
14
+ p.add_argument('truth'); p.add_argument('predictions')
15
+ p.add_argument('--tolerances',default='0,0.5,1,2',help='Comma-separated nonnegative endpoint tolerances (seconds)')
16
+ p.add_argument('--output',default='eventscore.html'); p.add_argument('--json',dest='json_output')
17
+ args=p.parse_args(argv)
18
+ try:
19
+ truth,predictions=load_csv(args.truth),load_csv(args.predictions)
20
+ tolerances=sorted(set(float(x) for x in args.tolerances.split(',')))
21
+ outputs=[Path(args.output)]+([Path(args.json_output)] if args.json_output else [])
22
+ inputs={Path(args.truth).resolve(),Path(args.predictions).resolve()}
23
+ if len({x.resolve() for x in outputs}) != len(outputs) or any(x.resolve() in inputs or x.exists() for x in outputs):
24
+ raise ValueError('Outputs must be distinct new files, separate from inputs')
25
+ html=report(truth,predictions,tolerances)
26
+ for x in outputs: x.parent.mkdir(parents=True,exist_ok=True)
27
+ Path(args.output).write_text(html,encoding='utf-8')
28
+ if args.json_output:
29
+ Path(args.json_output).write_text(json.dumps([evaluate(truth,predictions,x) for x in tolerances],indent=2),encoding='utf-8')
30
+ print(f'Report: {args.output}')
31
+ return 0
32
+ except (ValueError,OSError,KeyError) as e:
33
+ p.exit(2,f'Error: {e}\n')
34
+
35
+ TEMPLATE=r'''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>EventScore Explorer</title>
36
+ <style>body{font:16px system-ui;background:#f5f7fb;color:#18273b;margin:0}main{max-width:1100px;margin:auto;padding:40px 24px}h1{font-size:32px;margin:8px 0}header p{max-width:780px;color:#536175}section{background:white;border:1px solid #dce3ed;border-radius:12px;padding:24px;margin:20px 0}select{font:inherit;padding:8px}#metrics{display:flex;flex-wrap:wrap;gap:28px;margin-top:24px}.metric b{display:block;font-size:30px}small,.muted{color:#536175}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:12px;border-bottom:1px solid #e1e7ef}svg{width:100%;min-width:650px}button{font:inherit;padding:9px 16px;cursor:pointer}.scroll{overflow:auto}.tag{font-size:12px;letter-spacing:2px;color:#285aaa}li{margin:8px 0}</style>
37
+ <main><header><div class="tag">TIME SERIES · EVALUATION LAB</div><h1>EventScore Explorer</h1><p>See which events matched, what was missed, and how an endpoint tolerance changes the result. Synthetic demo or your own CSV — all processing for this report is local.</p></header>
38
+ <section><label for="tolerance">Endpoint tolerance (seconds) </label><select id="tolerance"></select><div id="metrics"></div><p class="muted">One-to-one maximum-cardinality matching. Labels and recordings must agree. At zero tolerance, touching endpoints do not overlap. Signed onset offset = prediction start − truth start; negative means early.</p></section>
39
+ <section><h2>Event timeline</h2><p>Blue: ground truth · Green: matched prediction · Orange: unmatched prediction. Each event has its own row; times are relative to the recording.</p><div class="scroll" id="timeline"></div></section>
40
+ <section><h2>Why these events matched</h2><div class="scroll"><table><thead><tr><th>Truth</th><th>Prediction</th><th>Rule</th><th>Overlap (s)</th><th>Onset offset (s)</th></tr></thead><tbody id="pairs"></tbody></table></div><h3>Unmatched events</h3><ul id="unmatched"></ul><p id="ambiguity"></p><button id="download">Download evaluation JSON</button></section>
41
+ <footer>Prototype 0.1 · No true-negative count, specificity, or false alarms/hour is inferred without recording duration. Matching maximizes count, not temporal quality. This is an explicit evaluation policy, not a clinical standard.</footer></main>
42
+ <script type="application/json" id="data">__DATA__</script><script>
43
+ const data=JSON.parse(document.getElementById('data').textContent), select=document.getElementById('tolerance');
44
+ data.results.forEach((r,i)=>{const o=document.createElement('option');o.value=i;o.textContent=r.tolerance_seconds;select.append(o)});
45
+ function cell(row,value){const td=document.createElement('td');td.textContent=value;row.append(td)}
46
+ function render(){const r=data.results[Number(select.value)],m=document.getElementById('metrics');m.replaceChildren();
47
+ for(const [name,value] of [['Matched',r.tp],['False alarms',r.fp],['Missed',r.fn],['Precision',r.precision],['Recall',r.recall],['F1',r.f1]]){const box=document.createElement('div');box.className='metric';const b=document.createElement('b');b.textContent=value===null?'N/A':(name==='Precision'||name==='Recall'||name==='F1'?value.toFixed(3):value);box.append(b,document.createTextNode(name));m.append(box)}
48
+ const pairs=document.getElementById('pairs');pairs.replaceChildren();r.matches.forEach(p=>{const tr=document.createElement('tr');[p.truth,p.prediction,p.reason,p.overlap_seconds.toFixed(3),p.onset_offset_seconds.toFixed(3)].forEach(x=>cell(tr,x));pairs.append(tr)});
49
+ const ul=document.getElementById('unmatched');ul.replaceChildren();for(const [items,kind] of [[r.missed,'Missed'],[r.false_alarms,'False alarm']])for(const x of items){const li=document.createElement('li');li.textContent=kind+' · '+x.id+' — '+x.reason;ul.append(li)}if(!ul.children.length){const li=document.createElement('li');li.textContent='No unmatched events';ul.append(li)}
50
+ document.getElementById('ambiguity').textContent=r.ambiguous_truth.length?'Multiple eligible predictions: '+r.ambiguous_truth.join(', ')+'. Pair identities and onset offsets can depend on deterministic tie-breaking.':'No truth event has multiple eligible predictions.';
51
+ const host=document.getElementById('timeline');host.replaceChildren(); const ns='http://www.w3.org/2000/svg'; const records=[...new Set([...data.truth,...data.predictions].map(x=>x.recording))];
52
+ for(const record of records){const title=document.createElement('h3');title.textContent=record;host.append(title);const events=[...data.truth.filter(x=>x.recording===record).map(x=>({...x,kind:'truth'})),...data.predictions.filter(x=>x.recording===record).map(x=>({...x,kind:'prediction'}))]; const end=Math.max(1,...events.map(e=>e.end));const svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox',`0 0 1000 ${events.length*34+45}`);svg.setAttribute('role','img');svg.setAttribute('aria-label','Event intervals for '+record+' in seconds');function text(x,y,s){const n=document.createElementNS(ns,'text');n.setAttribute('x',x);n.setAttribute('y',y);n.setAttribute('font-size','13');n.textContent=s;svg.append(n)}text(260,20,'0 s');text(900,20,end.toFixed(2)+' s');events.forEach((e,i)=>{const y=35+i*34;text(0,y+17,e.kind+' '+e.id+' ['+e.label+']');const rect=document.createElementNS(ns,'rect');rect.setAttribute('x',260+e.start/end*680);rect.setAttribute('y',y);rect.setAttribute('width',Math.max(2,(e.end-e.start)/end*680));rect.setAttribute('height',22);rect.setAttribute('rx',3);rect.setAttribute('fill',e.kind==='truth'?'#386bc0':r.matches.some(p=>p.prediction===e.id)?'#168579':'#c5682a');const tip=document.createElementNS(ns,'title');tip.textContent=e.start+'–'+e.end+' seconds';rect.append(tip);svg.append(rect)});host.append(svg)} }
53
+ select.addEventListener('change',render);document.getElementById('download').onclick=()=>{const url=URL.createObjectURL(new Blob([JSON.stringify(data.results[Number(select.value)],null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='eventscore-evaluation.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)};render();
54
+ </script></html>'''
@@ -0,0 +1,94 @@
1
+ """Explicit, deterministic maximum-cardinality interval matching (not clinical scoring)."""
2
+ import csv
3
+ import math
4
+ from dataclasses import dataclass
5
+
6
+ @dataclass(frozen=True)
7
+ class Event:
8
+ id: str
9
+ start: float
10
+ end: float
11
+ label: str = 'event'
12
+ recording: str = 'default'
13
+
14
+ def __post_init__(self):
15
+ if not self.id or not self.label or not self.recording:
16
+ raise ValueError('id, label and recording must not be empty')
17
+ if not math.isfinite(self.start) or not math.isfinite(self.end) or self.start < 0 or self.end <= self.start:
18
+ raise ValueError(f'{self.id}: require finite 0 <= start < end (seconds)')
19
+
20
+ def load_csv(path):
21
+ with open(path, newline='', encoding='utf-8-sig') as f:
22
+ reader = csv.DictReader(f)
23
+ if not {'id', 'start', 'end'} <= set(reader.fieldnames or []):
24
+ raise ValueError('CSV requires id,start,end; optional label,recording')
25
+ return validate([Event(r['id'], float(r['start']), float(r['end']), r.get('label', 'event'), r.get('recording', 'default')) for r in reader])
26
+
27
+ def validate(events):
28
+ events = list(events)
29
+ if len({e.id for e in events}) != len(events):
30
+ raise ValueError('Event IDs must be unique within each input')
31
+ return events
32
+
33
+ def evaluate(truth, predictions, tolerance=0.0):
34
+ """Overlap of half-open intervals OR positive tolerance covering endpoint gap.
35
+
36
+ One prediction matches at most one truth. Maximum-cardinality bipartite
37
+ matching, deterministic sorted traversal; not a minimum-delay assignment.
38
+ Tolerance never crosses recording or label boundaries.
39
+ """
40
+ if not math.isfinite(tolerance) or tolerance < 0:
41
+ raise ValueError('tolerance must be finite and nonnegative')
42
+ truth = sorted(validate(truth), key=lambda e: (e.recording,e.label,e.start,e.end,e.id))
43
+ predictions = sorted(validate(predictions), key=lambda e: (e.recording,e.label,e.start,e.end,e.id))
44
+ if len(truth) > 2000 or len(predictions) > 2000:
45
+ raise ValueError('Prototype supports at most 2000 events per input')
46
+ def eligible(t,p):
47
+ if (t.recording,t.label) != (p.recording,p.label):
48
+ return False
49
+ overlap = min(t.end,p.end)-max(t.start,p.start)
50
+ gap = max(t.start-p.end,p.start-t.end,0)
51
+ return overlap > 0 or (tolerance > 0 and gap <= tolerance)
52
+ edges = [[j for j,p in enumerate(predictions) if eligible(t,p)] for t in truth]
53
+ # Iterative augmenting paths avoid recursion depth failures on long graphs.
54
+ owner = {}
55
+ for start in range(len(truth)):
56
+ queue = [start]; seen_t = {start}; parent_p = {}; free = None
57
+ for i in queue:
58
+ for j in edges[i]:
59
+ if j in parent_p:
60
+ continue
61
+ parent_p[j] = i
62
+ if j not in owner:
63
+ free = j
64
+ break
65
+ nxt = owner[j]
66
+ if nxt not in seen_t:
67
+ seen_t.add(nxt); queue.append(nxt)
68
+ if free is not None:
69
+ break
70
+ if free is not None:
71
+ inverse = {i:j for j,i in owner.items()}
72
+ while True:
73
+ i = parent_p[free]
74
+ old = inverse.get(i)
75
+ owner[free] = i
76
+ if old is None:
77
+ break
78
+ free = old
79
+ pairs=[]
80
+ for j,i in sorted(owner.items(),key=lambda x:x[1]):
81
+ t,p = truth[i],predictions[j]
82
+ overlap = max(0,min(t.end,p.end)-max(t.start,p.start))
83
+ pairs.append({'truth':t.id,'prediction':p.id,'reason':'overlap' if overlap else 'within tolerance',
84
+ 'overlap_seconds':overlap,'gap_seconds':max(t.start-p.end,p.start-t.end,0),
85
+ 'onset_offset_seconds':p.start-t.start})
86
+ tp=len(pairs); fp=len(predictions)-tp; fn=len(truth)-tp
87
+ return {'tolerance_seconds':tolerance,'tp':tp,'fp':fp,'fn':fn,
88
+ 'precision':tp/len(predictions) if predictions else None,
89
+ 'recall':tp/len(truth) if truth else None,
90
+ 'f1':2*tp/(len(truth)+len(predictions)) if truth or predictions else None,
91
+ 'matches':pairs,
92
+ 'missed':[{'id':t.id,'reason':'candidate assigned to another event' if edges[i] else 'no eligible prediction'} for i,t in enumerate(truth) if i not in owner.values()],
93
+ 'false_alarms':[{'id':p.id,'reason':'candidate assigned to another event' if any(j in e for e in edges) else 'no eligible truth'} for j,p in enumerate(predictions) if j not in owner],
94
+ 'ambiguous_truth':[truth[i].id for i,e in enumerate(edges) if len(e)>1]}
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: eventscore-explorer
3
+ Version: 0.1.0
4
+ Summary: Explain event-level time-series evaluation
5
+ Author: Ryu Koda
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/R-Koda/eventscore-explorer
8
+ Project-URL: Issues, https://github.com/R-Koda/eventscore-explorer/issues
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # EventScore Explorer
15
+
16
+ [![Tests](https://github.com/R-Koda/eventscore-explorer/actions/workflows/tests.yml/badge.svg)](https://github.com/R-Koda/eventscore-explorer/actions/workflows/tests.yml)
17
+ **Explain why time-series events match — and why the score changes.**
18
+
19
+ A small, dependency-free Python toolkit that turns truth and prediction intervals into an interactive HTML report. Inspect one-to-one matches, misses, false alarms, and signed onset offsets. Switch endpoint tolerances without a server.
20
+
21
+ 実際にどのイベントが対応したかを説明する、時系列評価ツールです。許容時間を切り替えてスコアの変化を確認できます。
22
+
23
+ ## Install from PyPI / pipでインストール
24
+
25
+ Python 3.10 or later. Create and activate a virtual environment first:
26
+
27
+ ```sh
28
+ python3 -m venv .venv
29
+ ```
30
+
31
+ macOS / Linux: `source .venv/bin/activate`
32
+
33
+ Windows PowerShell: `.venv\Scripts\Activate.ps1`
34
+
35
+ ```sh
36
+ python -m pip install eventscore-explorer
37
+ eventscore --help
38
+ ```
39
+
40
+ The help screen confirms that the command is available. Install only the tools you need.
41
+ [PyPI package](https://pypi.org/project/eventscore-explorer/) · [Source and examples](https://github.com/R-Koda/eventscore-explorer)
42
+
43
+ サンプルCSV・Notebookなどはpipでは配置されません。公開デモはブラウザで開けます。自分でサンプルを実行する場合はGitHubの「Code → Download ZIP」で取得・展開し、READMEの相対パスをそのフォルダー内から指定してください。
44
+
45
+ ## Article & live demo / 紹介記事と公開デモ
46
+
47
+ - [個別ガイド / EventScore Explorer:時系列の見逃し・誤検出を確かめる](https://ryu-koda.com/blog/eventscore-explorer-event-evaluation/)
48
+ - [紹介記事:データを分ける・評価する・公開する。Pythonの小さな確認ツールを3つ作りました](https://ryu-koda.com/blog/python-tools-splittrace-eventscore-notebook/) — Japanese overview of SplitTrace, EventScore Explorer, and Notebook Publish Check.
49
+ - [公開デモをブラウザで開く / Open the live demo](https://ryu-koda.com/articles/python-tools/eventscore-demo.html) — Synthetic data only; no installation required.
50
+
51
+ 公開デモは合成データの出力例です。自分のファイルをアップロードする画面ではありません。
52
+ The hosted demo is a sample report, not a file-upload service.
53
+
54
+ ## Try the demo
55
+
56
+ Open [`examples/demo.html`](https://github.com/R-Koda/eventscore-explorer/blob/main/examples/demo.html) in a browser after downloading the repository. All data is synthetic. At 0 seconds the demo has 2 matches, 3 false alarms and 2 misses. At 0.5 seconds it has 3 matches, 2 false alarms and 1 miss.
57
+
58
+ ```sh
59
+ python3 -m venv .venv
60
+ . .venv/bin/activate
61
+ python -m pip install .
62
+ eventscore examples/truth.csv examples/predictions.csv --tolerances 0,0.5,1,2 --output reports/events.html --json reports/events.json
63
+ ```
64
+
65
+ Without installing: prefix the same arguments with `PYTHONPATH=src python3 -m eventscore_explorer` instead of `eventscore`.
66
+
67
+ Python 3.10+. Runtime has no third-party dependencies.
68
+
69
+ ## Input
70
+
71
+ Each CSV requires `id,start,end`; optional `label,recording` default to `event,default`. IDs must be unique within each input. Times are finite seconds with `0 <= start < end`. The two files must use the same time origin within each recording. Multiple recordings and labels are never matched across boundaries.
72
+
73
+ ```csv
74
+ id,start,end,label,recording
75
+ T1,10,15,event,recording-1
76
+ ```
77
+
78
+ ## Exact scoring policy
79
+
80
+ - Intervals are half-open. At tolerance 0, only positive-duration overlap qualifies.
81
+ - At positive tolerance, disjoint or touching intervals qualify when their endpoint gap is at most that tolerance. This is **not** an onset-only collar.
82
+ - One truth and one prediction per match; maximum-cardinality bipartite matching maximizes matched count. Sorted traversal is deterministic; it does not optimize overlap or latency. Ambiguous matches are flagged.
83
+ - Precision = matches / predictions; recall = matches / truth; F1 = 2 × matches / (truth + predictions). Undefined denominators are reported as `null` / N/A.
84
+ - Onset offset = prediction start − truth start. Negative means early; it is not clipped into a positive detection delay.
85
+ - No prediction merging, point adjustment, label weighting, true-negative metrics or clinical scoring is applied. Recording duration is not supplied, so false alarms/hour is not computed.
86
+
87
+ Use the Python API: `from eventscore_explorer.core import Event, evaluate`.
88
+
89
+ ## Scope and limits
90
+
91
+ Experimental 0.1. Input is capped at 2,000 events per file; matching can be expensive on dense graphs. Every requested tolerance is precomputed. Reports contain event IDs and times: review before publishing. Report files are never overwritten, and input CSVs cannot be output targets.
92
+
93
+ This tool complements [TimeEval](https://github.com/TimeEval/timeeval) and [affiliation metrics](https://github.com/ahstat/affiliation-metrics-py). It does not implement those metrics or claim equivalence. Its focus is explaining a simple, explicit matching policy.
94
+
95
+ ## Development
96
+
97
+ ```sh
98
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
99
+ ```
100
+
101
+ Tests cover non-greedy matching, endpoint rules, duplicate alarms, recording/label isolation, empty inputs, determinism, validation and safe HTML data embedding.
102
+
103
+ MIT licensed. Synthetic examples only; not a validated clinical evaluation standard.
@@ -0,0 +1,10 @@
1
+ eventscore_explorer/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ eventscore_explorer/__main__.py,sha256=ee5vE0xcUZM3fPmxicvcw-IJXkm6nOwxARREHBWpF8Q,47
3
+ eventscore_explorer/cli.py,sha256=cQvDJZovgrDwFcXoDcGQz2Cb8XmatT8DIaeNMvtWX4I,8114
4
+ eventscore_explorer/core.py,sha256=0Qc4mr3lbgVL5zu3aBy-4bK374Vgw_5eFll67vloiHM,4618
5
+ eventscore_explorer-0.1.0.dist-info/licenses/LICENSE,sha256=g92amfOtr0v2qgLJkCaw1xM9vtUm6PuYqjNM5rUYReE,1065
6
+ eventscore_explorer-0.1.0.dist-info/METADATA,sha256=pK4Awq7bs4slJxVhhCLYG7IDTTA3vf5W5VLn19kbaW8,5832
7
+ eventscore_explorer-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ eventscore_explorer-0.1.0.dist-info/entry_points.txt,sha256=YEVJkLtZ1VgzJtY3zYmcNA6eNjT4kOFYVIbWOCTqHVk,60
9
+ eventscore_explorer-0.1.0.dist-info/top_level.txt,sha256=CsFPLHOVNygQ_uLlR0UEV8Rig76Z0aDBwPl-vm8iyi0,20
10
+ eventscore_explorer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ eventscore = eventscore_explorer.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryu Koda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ eventscore_explorer