roh8exe commited on
Commit
60b21d3
·
verified ·
1 Parent(s): a25eec7

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +18 -0
  2. RUN.md +49 -0
  3. code/OpenTSLM/.github/workflows/publish.yml +62 -0
  4. code/OpenTSLM/.github/workflows/static-analysis.yml +30 -0
  5. code/OpenTSLM/.gitignore +22 -0
  6. code/OpenTSLM/.linkspector.yml +12 -0
  7. code/OpenTSLM/.python-version +7 -0
  8. code/OpenTSLM/CITATION.cff +69 -0
  9. code/OpenTSLM/CONTRIBUTORS.md +17 -0
  10. code/OpenTSLM/LICENSE.md +9 -0
  11. code/OpenTSLM/LICENSES/MIT.txt +9 -0
  12. code/OpenTSLM/README.md +385 -0
  13. code/OpenTSLM/REUSE.toml +6 -0
  14. code/OpenTSLM/assets/ASL_Logo2.svg +44 -0
  15. code/OpenTSLM/assets/ASLwhite.svg +1 -0
  16. code/OpenTSLM/assets/CDHI_white.svg +0 -0
  17. code/OpenTSLM/assets/cdhi_logo.png +3 -0
  18. code/OpenTSLM/assets/ecg_rationale.png +3 -0
  19. code/OpenTSLM/assets/eth_cdhi_logo.png +3 -0
  20. code/OpenTSLM/assets/eth_logo.png +0 -0
  21. code/OpenTSLM/assets/har_rationale.png +3 -0
  22. code/OpenTSLM/assets/m4_caption.png +3 -0
  23. code/OpenTSLM/assets/schematic_overview_2.png +3 -0
  24. code/OpenTSLM/assets/schematic_overview_3.png +3 -0
  25. code/OpenTSLM/assets/sleep_rationale.png +3 -0
  26. code/OpenTSLM/assets/stanford_biodesign_logo.png +0 -0
  27. code/OpenTSLM/curriculum_learning.py +1732 -0
  28. code/OpenTSLM/data/.gitkeep +0 -0
  29. code/OpenTSLM/demo/huggingface/01_test_hf_tsqa.py +72 -0
  30. code/OpenTSLM/demo/huggingface/02_test_hf_m4.py +74 -0
  31. code/OpenTSLM/demo/huggingface/03_test_hf_har_cot.py +77 -0
  32. code/OpenTSLM/demo/huggingface/04_test_hf_sleep_cot.py +75 -0
  33. code/OpenTSLM/demo/huggingface/05_test_hf_ecg_qa_cot.py +79 -0
  34. code/OpenTSLM/evaluation/baseline/README.md +213 -0
  35. code/OpenTSLM/evaluation/baseline/common_evaluator.py +722 -0
  36. code/OpenTSLM/evaluation/baseline/common_evaluator_plot.py +459 -0
  37. code/OpenTSLM/evaluation/baseline/eval.sh +38 -0
  38. code/OpenTSLM/evaluation/baseline/eval_remaining.sh +18 -0
  39. code/OpenTSLM/evaluation/baseline/evaluate_all.py +92 -0
  40. code/OpenTSLM/evaluation/baseline/evaluate_ecg_qa.py +293 -0
  41. code/OpenTSLM/evaluation/baseline/evaluate_ecg_qa_plot.py +355 -0
  42. code/OpenTSLM/evaluation/baseline/evaluate_har.py +83 -0
  43. code/OpenTSLM/evaluation/baseline/evaluate_har_plot.py +113 -0
  44. code/OpenTSLM/evaluation/baseline/evaluate_pamap.py +75 -0
  45. code/OpenTSLM/evaluation/baseline/evaluate_sleep_cot.py +93 -0
  46. code/OpenTSLM/evaluation/baseline/evaluate_sleep_plot.py +120 -0
  47. code/OpenTSLM/evaluation/baseline/evaluate_tsqa.py +89 -0
  48. code/OpenTSLM/evaluation/baseline/evaluate_tsqa_plot.py +124 -0
  49. code/OpenTSLM/evaluation/baseline/gruver_llmtime_tokenizer.py +175 -0
  50. code/OpenTSLM/evaluation/baseline/openai_pipeline.py +46 -0
.gitattributes CHANGED
@@ -33,3 +33,21 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ code/OpenTSLM/assets/cdhi_logo.png filter=lfs diff=lfs merge=lfs -text
37
+ code/OpenTSLM/assets/ecg_rationale.png filter=lfs diff=lfs merge=lfs -text
38
+ code/OpenTSLM/assets/eth_cdhi_logo.png filter=lfs diff=lfs merge=lfs -text
39
+ code/OpenTSLM/assets/har_rationale.png filter=lfs diff=lfs merge=lfs -text
40
+ code/OpenTSLM/assets/m4_caption.png filter=lfs diff=lfs merge=lfs -text
41
+ code/OpenTSLM/assets/schematic_overview_2.png filter=lfs diff=lfs merge=lfs -text
42
+ code/OpenTSLM/assets/schematic_overview_3.png filter=lfs diff=lfs merge=lfs -text
43
+ code/OpenTSLM/assets/sleep_rationale.png filter=lfs diff=lfs merge=lfs -text
44
+ code/OpenTSLM/src/opentslm/time_series_datasets/ecg_qa/dataset_distribution.png filter=lfs diff=lfs merge=lfs -text
45
+ code/OpenTSLM/src/opentslm/time_series_datasets/ecg_qa/ecg_example_real.png filter=lfs diff=lfs merge=lfs -text
46
+ data/ecg_qa_cot/ecg_qa_cot_test.csv filter=lfs diff=lfs merge=lfs -text
47
+ data/ecg_qa_cot/ecg_qa_cot_train.csv filter=lfs diff=lfs merge=lfs -text
48
+ data/ecg_qa_cot/ecg_qa_cot_val.csv filter=lfs diff=lfs merge=lfs -text
49
+ results/Llama_3_2_1B/OpenTSLMSP/stage2_captioning/results/test_predictions.jsonl filter=lfs diff=lfs merge=lfs -text
50
+ results/Llama_3_2_1B/OpenTSLMSP/stage2_captioning/results/test_predictions_rank_0.jsonl filter=lfs diff=lfs merge=lfs -text
51
+ results/Llama_3_2_1B/OpenTSLMSP/stage3_cot/results/test_predictions.jsonl filter=lfs diff=lfs merge=lfs -text
52
+ results/Llama_3_2_1B/OpenTSLMSP/stage3_cot/results/test_predictions_rank_0.jsonl filter=lfs diff=lfs merge=lfs -text
53
+ results/Llama_3_2_1B/OpenTSLMSP/stage5_ecg_cot/results/test_predictions_rank_0.jsonl filter=lfs diff=lfs merge=lfs -text
RUN.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TimeAgent — Llama-3.2-1B baseline: finish the ECG eval
2
+
3
+ This bundle lets another server complete the ONLY remaining step for the
4
+ Llama-3.2-1B OpenTSLM-SoftPrompt **baseline**: the stage-5 ECG-QA-CoT evaluation.
5
+ Stages 1-4 (TSQA, M4, HAR, Sleep) are fully complete, and ECG **training** is
6
+ done (early-stopped @ epoch 14). Only the ECG generation eval remains — it kept
7
+ OOM-crashing on our shared/contended GPUs.
8
+
9
+ ## Contents
10
+ - `results/Llama_3_2_1B/` — all stage checkpoints + metrics (stages 1-4 have
11
+ metrics.json so they're skipped; stage5_ecg_cot has the trained checkpoint,
12
+ no metrics.json → the eval will run).
13
+ - `data/ecg_qa_cot/` — ECG-QA-CoT CSV splits (train/val/test).
14
+ - `data/ptbxl.tar.gz` — PTB-XL records, tarred. On target: `cd data && tar xzf ptbxl.tar.gz` before running.
15
+ - `code/OpenTSLM/` — the code (includes our fixes: greedy eval `do_sample=False`,
16
+ sdpa attention, left-padded batched generation, `EVAL_BATCH_SIZE` env).
17
+ - `requirements-key.txt` — key package versions we ran with.
18
+
19
+ ## Setup on the target server
20
+ ```bash
21
+ # 1) python env (Python 3.13; a venv or conda env)
22
+ python3 -m venv venv && source venv/bin/activate
23
+ pip install torch transformers peft wfdb datasets accelerate huggingface_hub numpy scipy pandas einops open_flamingo
24
+ # (match requirements-key.txt if you hit version issues)
25
+
26
+ # 2) wire the checkpoints + data into the code tree (symlinks)
27
+ cd code/OpenTSLM
28
+ ln -sfn "$(realpath ../../results)" results
29
+ ln -sfn "$(realpath ../../data)" src/data
30
+
31
+ # 3) HF token (Llama-3.2-1B is gated; the base model auto-downloads)
32
+ export HF_TOKEN=<your_hf_token_with_llama_access>
33
+ export HF_HOME=/path/with/space/hf_cache
34
+ ```
35
+
36
+ ## Run the ECG eval
37
+ ```bash
38
+ cd code/OpenTSLM
39
+ # On a CLEAN 95GB GPU (no other tenants) you can batch the eval for speed:
40
+ EVAL_BATCH_SIZE=4 python3 -u curriculum_learning.py --model OpenTSLMSP --llm_id meta-llama/Llama-3.2-1B
41
+ # If it OOMs, drop to EVAL_BATCH_SIZE=2, or omit it for batch=1 (slow but always fits).
42
+ ```
43
+ It will: skip stages 1-4 (metrics exist) → load the stage5 ECG checkpoint →
44
+ skip training (done) → run the ECG eval → write
45
+ `results/Llama_3_2_1B/OpenTSLMSP/stage5_ecg_cot/results/metrics.json`.
46
+ When that file appears, the 5-stage baseline is COMPLETE.
47
+
48
+ Note: eval is greedy (deterministic). ECG sequences are ~7.7k tokens, so batched
49
+ generation is memory-heavy — batch=4 needs a mostly-free 95GB GPU.
code/OpenTSLM/.github/workflows/publish.yml ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ name: "Publish to PyPI"
7
+
8
+ on:
9
+ workflow_dispatch:
10
+ inputs:
11
+ target:
12
+ description: 'Target'
13
+ required: true
14
+ default: 'PyPI'
15
+ type: choice
16
+ options:
17
+ - PyPI
18
+ - TestPyPI
19
+ # push:
20
+ # tags:
21
+ # # Publish on any tag starting with a `v`, e.g., v0.1.0
22
+ # - v*
23
+
24
+ env:
25
+ BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
26
+
27
+ run-name: Publish to ${{ inputs.target }}
28
+
29
+ jobs:
30
+ run:
31
+ runs-on: ubuntu-latest
32
+ environment:
33
+ name: pypi
34
+ permissions:
35
+ id-token: write
36
+ contents: read
37
+ steps:
38
+ - name: Verify branch
39
+ run: |
40
+ echo "Branch name is $BRANCH_NAME"
41
+ if [ "${{ inputs.target }}" == "PyPI" ] && [ "$BRANCH_NAME" != "main" ]; then
42
+ echo "::error title=Invalid branch::Only the main branch can be published to PyPI."
43
+ exit 1
44
+ fi
45
+ - name: Checkout
46
+ uses: actions/checkout@v5
47
+ - name: Install uv
48
+ uses: astral-sh/setup-uv@v7
49
+ - name: Build
50
+ run: uv build
51
+ # Check that basic features work and we didn't miss to include crucial files
52
+ - name: Smoke test (wheel)
53
+ run: uv run --isolated --no-project --with dist/*.whl test/smoke_test.py
54
+ - name: Smoke test (source distribution)
55
+ run: uv run --isolated --no-project --with dist/*.tar.gz test/smoke_test.py
56
+ - name: Publish
57
+ run: uv publish ${{ inputs.target == 'TestPyPI' && '--index testpypi' || '' }}
58
+ - name: Summary
59
+ run: |
60
+ echo "### Published OpenTSLM to ${{ inputs.target }} :rocket:" >> $GITHUB_STEP_SUMMARY
61
+ echo "Version: `$(uv version --short)`" >> $GITHUB_STEP_SUMMARY
62
+ echo "URL: https://${{ inputs.target == 'TestPyPI' && 'test.' || '' }}pypi.org/project/opentslm/$(uv version --short)/" >> $GITHUB_STEP_SUMMARY
code/OpenTSLM/.github/workflows/static-analysis.yml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ name: Static Analysis
7
+
8
+ on:
9
+ push:
10
+ branches:
11
+ - main
12
+ pull_request:
13
+ workflow_dispatch:
14
+ workflow_call:
15
+
16
+ concurrency:
17
+ group: Static-Analysis-${{ github.ref }}
18
+ cancel-in-progress: true
19
+
20
+ jobs:
21
+ reuse_action:
22
+ name: REUSE Compliance Check
23
+ uses: StanfordBDHG/.github/.github/workflows/reuse.yml@v2
24
+ permissions:
25
+ contents: read
26
+ markdownlinkcheck:
27
+ name: Markdown Link Check
28
+ uses: StanfordBDHG/.github/.github/workflows/markdown-link-check.yml@v2
29
+ permissions:
30
+ contents: read
code/OpenTSLM/.gitignore ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ .venv
7
+ .vscode
8
+ __pycache__
9
+ .DS_STORE
10
+
11
+ raw_data
12
+
13
+ **/data/*
14
+ !**/data/.gitkeep
15
+
16
+ *.ts
17
+ *.zip
18
+ upload_to_huggingface.py
19
+
20
+ dist/
21
+
22
+ *.license
code/OpenTSLM/.linkspector.yml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ dirs:
7
+ - .
8
+ useGitIgnore: true
9
+ ignorePatterns:
10
+ - pattern: "doc:/"
11
+ - pattern: "http://localhost"
12
+ - pattern: "https://doi.org"
code/OpenTSLM/.python-version ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ #
2
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+ #
6
+
7
+ 3.12
code/OpenTSLM/CITATION.cff ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #
2
+ # This source file is part of the OpenTSLM open-source project
3
+ #
4
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
5
+ #
6
+ # SPDX-License-Identifier: MIT
7
+ #
8
+
9
+ message: "If you use this software or model, please cite it as below."
10
+ title: "OpenTSLM: Time-Series Language Models for Reasoning over Multivariate Medical Text- and Time-Series Data"
11
+ authors:
12
+ - family-names: "Langer"
13
+ given-names: "Patrick"
14
+ affiliation: "ETH Zurich"
15
+ - family-names: "Kaar"
16
+ given-names: "Thomas"
17
+ affiliation: "Stanford University"
18
+ - family-names: "Rosenblattl"
19
+ given-names: "Max"
20
+ affiliation: "Technical University of Munich"
21
+ - family-names: "Xu"
22
+ given-names: "Maxwell A."
23
+ affiliation: "University of Illinois Urbana-Champaign / Google Research"
24
+ - family-names: "Chow"
25
+ given-names: "Winnie"
26
+ affiliation: "Stanford University"
27
+ - family-names: "Maritsch"
28
+ given-names: "Martin"
29
+ affiliation: "Amazon"
30
+ - family-names: "Verma"
31
+ given-names: "Aradhana"
32
+ affiliation: "Stanford University"
33
+ - family-names: "Han"
34
+ given-names: "Brian"
35
+ affiliation: "Stanford University"
36
+ - family-names: "Kim"
37
+ given-names: "Daniel Seung"
38
+ affiliation: "University of Washington"
39
+ - family-names: "Chubb"
40
+ given-names: "Henry"
41
+ affiliation: "Stanford University"
42
+ - family-names: "Ceresnak"
43
+ given-names: "Scott"
44
+ affiliation: "Stanford University"
45
+ - family-names: "Zahedivash"
46
+ given-names: "Aydin"
47
+ affiliation: "Stanford University"
48
+ - family-names: "Sandhu"
49
+ given-names: "Alexander Tarlochan Singh"
50
+ affiliation: "Stanford University"
51
+ - family-names: "Rodriguez"
52
+ given-names: "Fatima"
53
+ affiliation: "Stanford University"
54
+ - family-names: "McDuff"
55
+ given-names: "Daniel"
56
+ affiliation: "University of Washington / Google Research"
57
+ - family-names: "Fleisch"
58
+ given-names: "Elgar"
59
+ affiliation: "ETH Zurich / University of St. Gallen"
60
+ - family-names: "Aalami"
61
+ given-names: "Oliver"
62
+ affiliation: "Stanford University"
63
+ - family-names: "Barata"
64
+ given-names: "Filipe"
65
+ affiliation: "ETH Zurich"
66
+ - family-names: "Schmiedmayer"
67
+ given-names: "Paul"
68
+ affiliation: "Stanford University"
69
+ date-released: "2025-10-01"
code/OpenTSLM/CONTRIBUTORS.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
3
+ SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
4
+
5
+ SPDX-License-Identifier: MIT
6
+ -->
7
+
8
+ # OpenTSLM Contributors
9
+
10
+ * [Patrick Langer](https://github.com/RealLast)
11
+ * [Thomas Kaar](https://github.com/ThomasKaar)
12
+ * [Max Rosenblattl](https://github.com/max-rosenblattl)
13
+ * [Maxwell A. Xu](https://github.com/maxxu05)
14
+ * [Winnie Chow](https://github.com/winnie0617)
15
+ * [Martin Maritsch](https://github.com/masquare)
16
+ * [Ning Wang](https://github.com/numericunderflow06)
17
+ * [Paul Schmiedmayer](https://github.com/PSchmiedmayer)
code/OpenTSLM/LICENSE.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
code/OpenTSLM/LICENSES/MIT.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
code/OpenTSLM/README.md ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
3
+ SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
4
+
5
+ SPDX-License-Identifier: MIT
6
+ -->
7
+
8
+ # OpenTSLM: Time-Series Language Models for Reasoning over Multivariate Medical Text- and Time-Series Data
9
+ [![PyPI - Version](https://img.shields.io/pypi/v/opentslm)](https://pypi.org/project/opentslm)
10
+ [![DOI](https://img.shields.io/badge/DOI-10.48550/arXiv.2510.02410-blue.svg)](https://doi.org/10.48550/arXiv.2510.02410)
11
+ [![Static Analysis](https://github.com/StanfordBDHG/OpenTSLM/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/StanfordBDHG/OpenTSLM/actions/workflows/static-analysis.yml)
12
+
13
+
14
+ Large Language Models (LLMs) have emerged as powerful tools for interpreting multimodal data (e.g., images, audio, text), often surpassing specialized models. In medicine, they hold particular promise for synthesizing large volumes of clinical information into actionable insights and patient-facing digital health applications. Yet, a major limitation remains their inability to handle time series data. To overcome this gap, we present OpenTSLM, a family of Time Series Language Models (TSLMs) created by integrating time series as a native modality to pretrained Large Language Models, enabling natural-language prompting and reasoning over multiple time series of any length [...] **[🔗 Read the full paper](https://doi.org/10.48550/arXiv.2510.02410)**
15
+
16
+ <p align="center">
17
+ <img src="https://raw.githubusercontent.com/StanfordBDHG/OpenTSLM/main/assets/schematic_overview_3.png" alt="Schematic Overview" width="100%">
18
+ </p>
19
+
20
+
21
+ ## Examples
22
+
23
+ OpenTSLM models can reason over multiple time series of any length at once, generating findings, captions, and rationales in natural language. We tested these models across a wide range of tasks spanning Human Activity Recognition (HAR) from 3-axis acceleration data, sleep staging from EEG readings, 12-lead ECG question answering, and time series captioning. Some examples are shown below, more are available in the paper.
24
+
25
+ <p align="center">
26
+ <img src="https://raw.githubusercontent.com/StanfordBDHG/OpenTSLM/main/assets/ecg_rationale.png" alt="ECG Rationale" width="32%">
27
+ <img src="https://raw.githubusercontent.com/StanfordBDHG/OpenTSLM/main/assets/har_rationale.png" alt="HAR Rationale" width="32%">
28
+ <img src="https://raw.githubusercontent.com/StanfordBDHG/OpenTSLM/main/assets/m4_caption.png" alt="M4 Caption" width="34%">
29
+
30
+ </p>
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install opentslm
36
+ ```
37
+
38
+
39
+ ## LLM Setup
40
+
41
+ OpenTSLM is designed to work with Llama and Gemma models, with Llama 3.2 1B as the default. These models are stored in Hugging Face repositories which may require access permissions. Follow these steps to gain access and download:
42
+
43
+ 1. **Request Access (for Llama models)**
44
+ Visit the Llama model repository (e.g., https://huggingface.co/meta-llama/Llama-3.2-1B) or Gemma models repository (https://huggingface.co/google/gemma-3-270m) and request access from Meta.
45
+
46
+ 2. **Authenticate with Hugging Face**
47
+ Log in to your Hugging Face account and configure the CLI:
48
+
49
+ ```bash
50
+ huggingface-cli login
51
+ ```
52
+
53
+ 3. **Create an API Token**
54
+ - Go to your Hugging Face settings: https://huggingface.co/settings/tokens
55
+ - Generate a new token with `read` scope.
56
+ - Copy the token for CLI login.
57
+
58
+ ### Supported Models
59
+
60
+ OpenTSLM has been tested and works with the following models:
61
+
62
+ **Llama Models:**
63
+
64
+ - **meta-llama/Llama-3.2-1B** (default)
65
+ - **meta-llama/Llama-3.2-3B**
66
+
67
+ **Gemma Models:**
68
+
69
+ - **google/gemma-3-270m**
70
+ - **google/gemma-3-1b-pt**
71
+
72
+ Other variants may work but have not been extensively tested.
73
+
74
+
75
+
76
+ ## 🤗 Quickstart with pretrained models on HuggingFace
77
+
78
+ A factory class called `OpenTSLM` for easily loading pre-trained models from Hugging Face Hub. The `load_pretrained` method automatically detects the model type and returns the appropriate model instance.
79
+
80
+ There are [demo scripts](demo/huggingface/) available which use the following minimal code. If you want to create your own applications, create a new file in **this repo folder** and use the following code as start:
81
+
82
+ ```python
83
+ from opentslm import OpenTSLM
84
+ from opentslm.time_series_datasets.TSQADataset import TSQADataset
85
+ from opentslm.time_series_datasets.util import extend_time_series_to_match_patch_size_and_aggregate
86
+ from torch.utils.data import DataLoader
87
+ from opentslm.model_config import PATCH_SIZE
88
+
89
+ REPO_ID = "OpenTSLM/llama-3.2-1b-tsqa-sp"
90
+
91
+ # Use CPU or CUDA for inference. MPS does NOT work for pretrained HF checkpoints.
92
+ model = OpenTSLM.load_pretrained(REPO_ID, device="cuda" if torch.cuda.is_available() else "cpu")
93
+ test_dataset = TSQADataset("test", EOS_TOKEN=model.get_eos_token())
94
+
95
+ test_loader = DataLoader(
96
+ test_dataset,
97
+ shuffle=False,
98
+ batch_size=1,
99
+ collate_fn=lambda batch: extend_time_series_to_match_patch_size_and_aggregate(
100
+ batch, patch_size=PATCH_SIZE
101
+ ),
102
+ )
103
+
104
+ for i, batch in enumerate(test_loader):
105
+ predictions = model.generate(batch, max_new_tokens=200)
106
+ for sample, pred in zip(batch, predictions):
107
+ print("Question:", sample.get("pre_prompt", "N/A"))
108
+ print("Answer:", sample.get("answer", "N/A"))
109
+ print("Output:", pred)
110
+ if i >= 4:
111
+ break
112
+ ```
113
+
114
+ ## Building and finetuning your own models
115
+
116
+ To run the demos and use finetuning scripts **clone the repository** and set up all dependencies. We recommend using [uv](https://docs.astral.sh/uv/) to set up the environment, but you can also use pip:
117
+
118
+ ```bash
119
+ git clone https://github.com/StanfordBDHG/OpenTSLM.git
120
+
121
+
122
+ # uv environment management (recommended). Installs uv if it does not exist and creates the virtual environment
123
+ command uv > /dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
124
+ uv sync --all-groups
125
+ source .venv/bin/activate
126
+
127
+
128
+ # or alternatively install via pip:
129
+ pip install -r requirements.txt
130
+
131
+ ```
132
+
133
+ ### HuggingFace Demo Scripts
134
+
135
+ We provide ready-to-use demo scripts in the `demo/huggingface/` directory that demonstrate how to load pretrained models from HuggingFace Hub and run inference on the evaluation sets for each task:
136
+
137
+ - **`01_test_hf_tsqa.py`** - Test TSQA (Time Series Question Answering) models
138
+ - **`02_test_hf_m4.py`** - Test M4 (Time Series Captioning) models
139
+ - **`03_test_hf_har_cot.py`** - Test HAR CoT (Human Activity Recognition Chain-of-Thought) models
140
+ - **`04_test_hf_sleep_cot.py`** - Test Sleep CoT (Sleep Stage Classification) models
141
+ - **`05_test_hf_ecg_qa_cot.py`** - Test ECG QA CoT (ECG Question Answering) models
142
+
143
+ Each script:
144
+ 1. Downloads the model checkpoint from HuggingFace Hub automatically (change repo id as neededs)
145
+ 2. Loads the corresponding test dataset
146
+ 3. Runs inference on the evaluation set
147
+ 4. Prints model outputs with sample information
148
+
149
+ **Note:** The scripts above use the OpenTSLM-SP models except for ECG-QA, as they require less VRAM and should run on most hardware. Change the model checkpoints as needed in each file.
150
+
151
+ **Usage:**
152
+
153
+ ```bash
154
+ # Run any of the demo scripts
155
+ python demo/huggingface/01_test_hf_tsqa.py
156
+ python demo/huggingface/02_test_hf_m4.py
157
+ python demo/huggingface/03_test_hf_har_cot.py
158
+ python demo/huggingface/04_test_hf_sleep_cot.py
159
+ python demo/huggingface/05_test_hf_ecg_qa_cot.py
160
+ ```
161
+
162
+ **Customizing the model:**
163
+
164
+ Edit the `REPO_ID` variable at the top of each script to test different model variants. For example:
165
+
166
+ ```python
167
+ # In 01_test_hf_tsqa.py
168
+ REPO_ID = "OpenTSLM/llama-3.2-1b-tsqa-sp" # Soft Prompt model
169
+ # or
170
+ REPO_ID = "OpenTSLM/llama-3.2-1b-tsqa-flamingo" # Flamingo model
171
+ ```
172
+
173
+ **Available models on HuggingFace:**
174
+
175
+ All pretrained models are available under the `OpenTSLM` organization on HuggingFace Hub. Model names follow the pattern:
176
+ - `OpenTSLM/{base_model}-{dataset}-{model_type}`
177
+ - `base_model`: `llama-3.2-1b`, `llama-3.2-3b`, `gemma-3-1b-pt`, `gemma-3-270m`
178
+ - `dataset`: `tsqa`, `m4`, `har`, `sleep`, `ecg`
179
+ - `model_type`: `sp` (Soft Prompt) or `flamingo` (Flamingo)
180
+
181
+ Example: `OpenTSLM/llama-3.2-1b-ecg-flamingo`
182
+
183
+ ## Training: Multi-stage training (Curriculum)
184
+
185
+ OpenTSLM uses curriculum learning with progressive training stages:
186
+
187
+ ### Training Stages
188
+
189
+ 1. **Stage 1 (MCQ)**: Multiple choice questions on time series data (TSQA dataset)
190
+ 2. **Stage 2 (Captioning)**: Generate detailed captions for time series (M4 dataset)
191
+ 3. **Stage 3 (CoT)**: Chain-of-thought reasoning on human activity recognition (HAR dataset)
192
+ 4. **Stage 4 (Sleep CoT)**: Chain-of-thought reasoning on sleep stage classification (SleepEDF dataset)
193
+ 5. **Stage 5 (ECG CoT)**: Chain-of-thought reasoning on ECG question answering (ECG QA dataset)
194
+
195
+ > **⚠️ MPS/CUDA Compatibility Warning:**
196
+ >
197
+ > If you are using Apple's MPS (Metal Performance Shaders) backend (e.g., on Mac with Apple Silicon), you may encounter issues with training or inference. **Checkpoints trained with CUDA (NVIDIA GPUs) may not yield good results or may not be fully compatible when loaded and run on MPS.** For best results, use the same device type (CUDA or MPS) for both training and inference. CUDA is preferred in general.
198
+
199
+ ### Quick Start
200
+
201
+ ```bash
202
+ # Run full curriculum with OpenTSLMFlamingo
203
+ python curriculum_learning.py --model OpenTSLMSP
204
+
205
+ # Run full curriculum with OpenTSLMSP
206
+ python curriculum_learning.py --model OpenTSLMFlamingo
207
+
208
+ # Run specific stages
209
+ python curriculum_learning.py --model OpenTSLMFlamingo --stages stage1_mcq
210
+ python curriculum_learning.py --model OpenTSLMFlamingo --stages stage2_captioning
211
+ python curriculum_learning.py --model OpenTSLMFlamingo --stages stage3_cot
212
+ python curriculum_learning.py --model OpenTSLMFlamingo --stages stage4_sleep_cot
213
+ python curriculum_learning.py --model OpenTSLMFlamingo --stages stage5_ecg_cot
214
+
215
+ # Run multiple stages
216
+ python curriculum_learning.py --model OpenTSLMFlamingo --stages stage1_mcq stage2_captioning stage3_cot
217
+
218
+ # Specify device
219
+ python curriculum_learning.py --model OpenTSLMFlamingo --device cuda
220
+
221
+ # Use different models
222
+ python curriculum_learning.py --model OpenTSLMFlamingo --llm_id meta-llama/Llama-3.2-1B
223
+ python curriculum_learning.py --model OpenTSLMFlamingo --llm_id google/gemma-3-270m
224
+
225
+ # Run only evaluation
226
+ python curriculum_learning.py --model OpenTSLMFlamingo --eval_only
227
+ ```
228
+
229
+ ### Command Line Arguments
230
+
231
+ - `--model`: Model type (`OpenTSLMSP` or `OpenTSLMFlamingo`)
232
+ - `--stages`: Stages to run (any combination of: `stage1_mcq`, `stage2_captioning`, `stage3_cot`, `stage4_sleep_cot`, `stage5_ecg_cot`)
233
+ - `--device`: Device to use (`cuda`, `mps`, `cpu`)
234
+ - `--eval_only`: Run evaluation only (requires an existing checkpoint for the stage)
235
+ - `--llm_id`: Model ID (default: `meta-llama/Llama-3.2-1B`, supports Llama and Gemma models)
236
+ - `--batch_size`: Batch size for training
237
+ - `--gradient_checkpointing`: Enable gradient checkpointing for memory efficiency
238
+ - `--verbose`: Enable verbose logging
239
+
240
+ ### Helper Scripts
241
+
242
+ Helper scripts for analysis, testing, and batch processing are available in the `scripts/` directory:
243
+
244
+ **Shell Scripts:**
245
+ - **`run_all_memory.sh`** - Run comprehensive memory usage analysis across all stages
246
+ - **`run_all_memory_missing.sh`** - Run memory analysis for missing stages only
247
+
248
+ **Python Scripts:**
249
+ - **`create_doctor_eval_dataset.py`** - Create evaluation dataset for doctor assessments
250
+ - **`get_memory_use.py`** - Analyze and report memory usage across stages
251
+ - **`plot_memory_usage.py`** - Visualize memory usage patterns
252
+ - **`plot_memory_simulation.py`** - Simulate and plot memory requirements
253
+ - **`plot_memory_simulation_per_length.py`** - Analyze memory usage by sequence length
254
+ - **`hf_test.py`** - Test HuggingFace model loading and inference
255
+
256
+ These scripts can be customized by editing the parameters directly or by passing command-line arguments.
257
+
258
+ ### Repository Naming Convention
259
+
260
+ - Repository IDs ending with `-sp` will load and return `OpenTSLMSP` models
261
+ - Repository IDs ending with `-flamingo` will load and return `OpenTSLMFlamingo` models
262
+
263
+
264
+ ## 📁 Results Structure
265
+
266
+ During training, the script creates a structured results directory:
267
+
268
+ ```
269
+ results/
270
+ ├── {llm_id}/
271
+ │ ├── OpenTSLMSP/
272
+ │ │ ├── stage1_mcq/
273
+ │ │ │ ├── checkpoints/
274
+ │ │ │ │ ├── best_model.pt
275
+ │ │ │ │ └── loss_history.txt
276
+ │ │ │ └── results/
277
+ │ │ │ ├── test_predictions.jsonl
278
+ │ │ │ └── metrics.json
279
+ │ │ ├── stage2_captioning/
280
+ │ │ │ ├── checkpoints/
281
+ │ │ │ │ ├── best_model.pt
282
+ │ │ │ │ └── loss_history.txt
283
+ │ │ │ └── results/
284
+ │ │ │ ├── test_predictions.jsonl
285
+ │ │ │ └── metrics.json
286
+ │ │ ├── stage3_cot/
287
+ │ │ │ ├── checkpoints/
288
+ │ │ │ │ ├── best_model.pt
289
+ │ │ │ │ └── loss_history.txt
290
+ │ │ │ └── results/
291
+ │ │ │ ├── test_predictions.jsonl
292
+ │ │ │ └── metrics.json
293
+ │ │ ├── stage4_sleep_cot/
294
+ │ │ │ ├── checkpoints/
295
+ │ │ │ │ ├── best_model.pt
296
+ │ │ │ │ └── loss_history.txt
297
+ │ │ │ └── results/
298
+ │ │ │ ├── test_predictions.jsonl
299
+ │ │ │ └── metrics.json
300
+ │ │ ├── stage5_ecg_cot/
301
+ │ │ │ ├── checkpoints/
302
+ │ │ │ │ ├── best_model.pt
303
+ │ │ │ │ └── loss_history.txt
304
+ │ │ │ └── results/
305
+ │ │ │ ├── test_predictions.jsonl
306
+ │ │ │ └── metrics.json
307
+ │ │ └── curriculum_results.json
308
+ │ └── OpenTSLMFlamingo/
309
+ │ ├── stage1_mcq/
310
+ │ ├── stage2_captioning/
311
+ │ ├── stage3_cot/
312
+ │ ├── stage4_sleep_cot/
313
+ │ ├── stage5_ecg_cot/
314
+ │ └── curriculum_results.json
315
+ ```
316
+
317
+ Each stage automatically loads the best model from the previous stage, ensuring proper curriculum progression. Results are organized by model ID (sanitized), then by model type and stage. The `{llm_id}` directory name is derived from the `--llm_id` parameter (e.g., `meta-llama/Llama-3.2-1B` becomes `Llama3_2_1B`, `google/gemma-3-1b-pt` becomes `gemma_3_1b_pt`).
318
+
319
+
320
+ ## Authors
321
+
322
+ This work was made possible through the collaborative efforts of an interdisciplinary team of researchers from computer science, medicine, and engineering. Thank you to all of the Co-authors of the TSLM publication:
323
+ - Patrick Langer (Stanford University, ETH Zurich, ETH Agentic Systems Lab)
324
+ - Thomas Kaar (Stanford University, TUM, ETH Agentic Systems Lab)
325
+ - Max Rosenblattl (Stanford University, TUM, ETH Agentic Systems Lab)
326
+ - Maxwell A. Xu (Google Research, University of Illinois Urbana-Champaign)
327
+ - Winnie Chow (Stanford University)
328
+ - Martin Maritsch (Amazon)
329
+ - Robert Jakob (ETH Zurich, ETH Agentic Systems Lab)
330
+ - Ning Wang (ETH Zurich, ETH Agentic Systems Lab)
331
+ - Aradhana Verma (Stanford University)
332
+ - Brian Han (Stanford University)
333
+ - Daniel Seung Kim (University of Washington)
334
+ - Henry Chubb (Stanford University)
335
+ - Scott Ceresnak (Stanford University)
336
+ - Aydin Zahedivash (Stanford University)
337
+ - Alexander Tarlochan Singh Sandhu (Stanford University)
338
+ - Fatima Rodriguez (Stanford University)
339
+ - Daniel McDuff (Google Research, University of Washington)
340
+ - Elgar Fleisch (ETH Zurich, University of St. Gallen)
341
+ - Oliver Aalami (Stanford University)
342
+ - Filipe Barata (ETH Zurich)
343
+ - Paul Schmiedmayer (Stanford University)
344
+
345
+ ## Contributing
346
+
347
+ Contributions to this project are welcome. Please make sure to read the [contribution guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md) and the [contributor covenant code of conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md) first.
348
+
349
+ You can find a list of all current contributors at [CONTRIBUTORS.md](CONTRIBUTORS.md).
350
+
351
+
352
+ ## Research Opportunities
353
+
354
+ Are you a student interested in advancing the frontiers of time-series language models and digital health research? We welcome students to get involved in our research projects!
355
+
356
+ Visit our [Student Research Opportunities page](http://bdh.stanford.edu/studentresearch) to learn more about current projects and how you can contribute to cutting-edge research at the intersection of AI and healthcare.
357
+
358
+ For researchers and project partners interested in collaboration opportunities, please reach out to us at **digitalhealthresearch@stanford.edu**.
359
+
360
+
361
+ ## License
362
+
363
+ This project is licensed under the MIT License.
364
+
365
+ OpenTSLM uses [REUSE specification](https://reuse.software/spec/) to ensure consistent and machine-readable licensing across the repository.
366
+
367
+ To add or update license headers, run:
368
+
369
+ ```bash
370
+ reuse annotate --recursive \
371
+ --copyright "Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)" \
372
+ --copyright "This source file is part of the OpenTSLM open-source project." \
373
+ --license MIT \
374
+ --skip-unrecognized \
375
+ .
376
+ ```
377
+
378
+
379
+
380
+ <div align="left">
381
+ <img src="https://raw.githubusercontent.com/StanfordBDHG/OpenTSLM/main/assets/stanford_biodesign_logo.png" alt="Stanford Biodesign" height="90">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
382
+ <img src="https://raw.githubusercontent.com/StanfordBDHG/OpenTSLM/main/assets/CDHI_white.svg" alt="ETH Centre for Digital Health Interventions" height="90">
383
+ <img src="https://raw.githubusercontent.com/StanfordBDHG/OpenTSLM/main/assets/ASLwhite.svg" alt="ETH Agentic Systems Lab" height="90">
384
+
385
+ </div>
code/OpenTSLM/REUSE.toml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ version = 1
2
+
3
+ [[annotations]]
4
+ path = ["assets/**", "data/**", "**/*.png", "*.svg", "*.png", "**/*.pt", "**/*.jsonl", "**/*.json", ".gitignore", "**/uv.lock", "LICENSE.md", "**/requirements.txt"]
5
+ SPDX-FileCopyrightText = "2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)"
6
+ SPDX-License-Identifier = "MIT"
code/OpenTSLM/assets/ASL_Logo2.svg ADDED
code/OpenTSLM/assets/ASLwhite.svg ADDED
code/OpenTSLM/assets/CDHI_white.svg ADDED
code/OpenTSLM/assets/cdhi_logo.png ADDED

Git LFS Details

  • SHA256: cfe53520a05e82822877485eef6da217f86dd60a565799f57e45e6e94abe79e3
  • Pointer size: 131 Bytes
  • Size of remote file: 197 kB
code/OpenTSLM/assets/ecg_rationale.png ADDED

Git LFS Details

  • SHA256: 0bc4e5868ca592f30ed7cd73bf0607e623f591fb39dab26131ce1db704969092
  • Pointer size: 131 Bytes
  • Size of remote file: 653 kB
code/OpenTSLM/assets/eth_cdhi_logo.png ADDED

Git LFS Details

  • SHA256: bff36aaab8170edd64255fbbc667562af86761f5e93949f5f399d881559f8655
  • Pointer size: 131 Bytes
  • Size of remote file: 167 kB
code/OpenTSLM/assets/eth_logo.png ADDED
code/OpenTSLM/assets/har_rationale.png ADDED

Git LFS Details

  • SHA256: 64c348296d9d5a418e742b20581b7d2f2717ce868612b13176aa39bb01e25322
  • Pointer size: 131 Bytes
  • Size of remote file: 655 kB
code/OpenTSLM/assets/m4_caption.png ADDED

Git LFS Details

  • SHA256: 2d5a1cc3d69f396a77a8c1473fb181983c2c87f8252112faedc1ce68ecd178ed
  • Pointer size: 131 Bytes
  • Size of remote file: 316 kB
code/OpenTSLM/assets/schematic_overview_2.png ADDED

Git LFS Details

  • SHA256: 12fc7c2d217810f010c11fb2a24f6e2a4568d11b5821b10afa0a8386962059c2
  • Pointer size: 131 Bytes
  • Size of remote file: 626 kB
code/OpenTSLM/assets/schematic_overview_3.png ADDED

Git LFS Details

  • SHA256: 8b0aee97d5bd6c305eaf3632cf62ba4a81437e176caa7938bcae4d5990a2fc4b
  • Pointer size: 131 Bytes
  • Size of remote file: 355 kB
code/OpenTSLM/assets/sleep_rationale.png ADDED

Git LFS Details

  • SHA256: d14078e8a37a5a7131f0525b673bf01f4295957c52c54567f97b87db808f3fa5
  • Pointer size: 131 Bytes
  • Size of remote file: 519 kB
code/OpenTSLM/assets/stanford_biodesign_logo.png ADDED
code/OpenTSLM/curriculum_learning.py ADDED
@@ -0,0 +1,1732 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+
7
+ import os
8
+
9
+ import json
10
+ import os as _os
11
+ import argparse
12
+ from typing import List, Optional, Dict, Any, Callable
13
+ from opentslm.time_series_datasets.TSQADataset import TSQADataset
14
+ from opentslm.time_series_datasets.m4.M4QADataset import M4QADataset
15
+ from opentslm.time_series_datasets.sleep.SleepEDFCoTQADataset import SleepEDFCoTQADataset
16
+ from opentslm.time_series_datasets.har_cot.HARCoTQADataset import HARCoTQADataset
17
+ from opentslm.time_series_datasets.ecg_qa.ECGQACoTQADataset import ECGQACoTQADataset
18
+ from opentslm.time_series_datasets.util import (
19
+ extend_time_series_to_match_patch_size_and_aggregate,
20
+ )
21
+ import torch
22
+ import torch.distributed as dist
23
+ from torch.optim import AdamW
24
+ from torch.nn.utils import clip_grad_norm_
25
+ from torch.utils.data import ConcatDataset, DataLoader, Dataset
26
+ from torch.utils.data.distributed import DistributedSampler
27
+ from torch.nn.parallel import DistributedDataParallel as DDP
28
+ from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
29
+ from torch.distributed.fsdp import (
30
+ CPUOffload,
31
+ MixedPrecision,
32
+ ShardingStrategy,
33
+ BackwardPrefetch,
34
+ FullStateDictConfig,
35
+ StateDictType,
36
+ )
37
+ from tqdm.auto import tqdm
38
+ from transformers import get_linear_schedule_with_warmup
39
+
40
+ from opentslm.model.encoder.TransformerCNNEncoder import TransformerCNNEncoder
41
+ from opentslm.model.llm.OpenTSLMFlamingo import OpenTSLMFlamingo
42
+ from opentslm.model.llm.OpenTSLMSP import OpenTSLMSP
43
+ from opentslm.model.projector.MLPProjector import MLPProjector
44
+ import datetime
45
+ from opentslm.logger import get_logger, set_global_verbose
46
+
47
+ from opentslm.model_config import (
48
+ BATCH_SIZE,
49
+ EARLY_STOP_PAT,
50
+ GRAD_CLIP_NORM,
51
+ LR_ENCODER,
52
+ LR_PROJECTOR,
53
+ NUM_EPOCHS,
54
+ PATCH_SIZE,
55
+ WARMUP_FRAC,
56
+ WEIGHT_DECAY,
57
+ )
58
+
59
+
60
+ # Global stage configuration - users can modify this to mix and match stages
61
+ CURRICULUM_STAGES = [
62
+ "stage1_mcq",
63
+ "stage2_captioning",
64
+ "stage3_cot",
65
+ "stage4_sleep_cot",
66
+ "stage5_ecg_cot",
67
+ ]
68
+
69
+
70
+ class CurriculumTrainer:
71
+ """
72
+ Curriculum learning trainer for OpenTSLM models.
73
+ Trains models stage by stage with shared training logic.
74
+ While this may look like a lot of code, it's actually quite modular.
75
+ We simply train either OpenTSLMSP or OpenTSLMFlamingo, both using the same training loop.
76
+ We train across different stages:
77
+ - stage1_mcq: Trains the model on a time-series MCQ dataset (TSQA)
78
+ - stage2_captioning: Trains the model on a time-series captioning dataset (M4 time series captioning)
79
+ - stage3_cot: Trains the model on a chain-of-thought reasoning dataset (HAR CoT)
80
+ - stage4_sleep_cot: Trains the model on sleep stage classification with chain-of-thought reasoning
81
+ - stage5_ecg_cot: Trains the model on ECG QA with chain-of-thought reasoning
82
+
83
+ Features:
84
+ - Automatic loss history tracking saved to loss_history.txt in each stage's checkpoints directory
85
+ - Loss history is appended to when resuming training, preserving all previous epochs
86
+ - Displays previous loss history when resuming training
87
+
88
+ If you run this script, you should be able to reproduce our results from the paper.
89
+ All datasets are automatically downloaded and processed.
90
+ """
91
+
92
+ def _sanitize_llm_id(self, llm_id: str) -> str:
93
+ """Sanitize llm_id for use in directory names (e.g., meta-llama/Llama-3.2-1B -> Llama3_2_1B)"""
94
+ if not llm_id:
95
+ return "unknown_llm"
96
+ # Take last part after /, replace . and - with _
97
+ name = llm_id.split("/")[-1]
98
+ name = name.replace(".", "_").replace("-", "_")
99
+ # Optionally, remove duplicate underscores
100
+ while "__" in name:
101
+ name = name.replace("__", "_")
102
+ return name
103
+
104
+ def __init__(
105
+ self,
106
+ model_type: str,
107
+ device: str = None,
108
+ gradient_checkpointing: bool = False,
109
+ dist_url: str = "env://",
110
+ dist_backend: str = "nccl",
111
+ local_rank: int = int(os.environ.get("LOCAL_RANK", 0)),
112
+ llm_id: str = None,
113
+ ):
114
+ """
115
+ Initialize the curriculum trainer.
116
+
117
+ Args:
118
+ model_type: Either 'OpenTSLMSP' or 'OpenTSLMFlamingo'
119
+ device: Device to use for training ('cuda', 'mps', or 'cpu')
120
+ gradient_checkpointing: Enable gradient checkpointing
121
+ dist_url: URL used to set up distributed training
122
+ dist_backend: Distributed backend
123
+ local_rank: Local GPU rank
124
+ llm_id: LLM model ID (e.g., 'google/medgemma-2b', 'meta-llama/Llama-3.2-1B')
125
+ """
126
+ self.model_type = model_type
127
+ self.device = device or self._get_device()
128
+ if self.device == "mps":
129
+ print(
130
+ "🚨 Warning: Using MPS, might not be fully compatible with the model. Use CUDA for best results."
131
+ )
132
+ self.llm_id = llm_id
133
+ self.llm_id_safe = self._sanitize_llm_id(llm_id)
134
+
135
+ # Distributed training parameters
136
+ self.gradient_checkpointing = gradient_checkpointing
137
+ self.dist_url = dist_url
138
+ self.dist_backend = dist_backend
139
+ self.local_rank = local_rank
140
+
141
+ # Initialize distributed training if needed
142
+ self.rank = 0
143
+ self.world_size = 1
144
+ if self._should_use_distributed():
145
+ self._init_distributed()
146
+
147
+ self.model = self._initialize_model()
148
+ self.results_dir = os.path.join("results", self.llm_id_safe, self.model_type)
149
+ self._create_results_dir()
150
+
151
+ def _get_device(self) -> str:
152
+ """Get the best available device."""
153
+ if torch.cuda.is_available():
154
+ return "cuda"
155
+ elif torch.backends.mps.is_available():
156
+ return "mps"
157
+ else:
158
+ return "cpu"
159
+
160
+ def _initialize_model(self):
161
+ """Initialize the specified model type."""
162
+ if self.model_type == "OpenTSLMSP":
163
+ model = OpenTSLMSP(llm_id=self.llm_id, device=self.device).to(self.device)
164
+
165
+ elif self.model_type == "OpenTSLMFlamingo":
166
+ model = OpenTSLMFlamingo(
167
+ cross_attn_every_n_layers=1,
168
+ gradient_checkpointing=self.gradient_checkpointing,
169
+ llm_id=self.llm_id,
170
+ device=self.device,
171
+ ).to(self.device)
172
+ else:
173
+ raise ValueError(f"Unknown model type: {self.model_type}")
174
+
175
+ # Use DDP for multi-GPU training (simpler and than FSDP)
176
+ if self.world_size > 1:
177
+ model = DDP(
178
+ model,
179
+ device_ids=[self.local_rank] if torch.cuda.is_available() else None,
180
+ )
181
+ if self.rank == 0:
182
+ print(f"Wrapped {self.model_type} with DDP for distributed training")
183
+
184
+ return model
185
+
186
+ def _get_cast_dtype(self, precision: str):
187
+ """Get cast dtype for mixed precision."""
188
+ if precision == "bf16":
189
+ return torch.bfloat16
190
+ elif precision == "fp16":
191
+ return torch.float16
192
+ else:
193
+ return None
194
+
195
+ def _create_results_dir(self):
196
+ """Create the results directory structure."""
197
+ os.makedirs(self.results_dir, exist_ok=True)
198
+ # model_dir now includes llm_id_safe
199
+ model_dir = self.results_dir
200
+ os.makedirs(model_dir, exist_ok=True)
201
+
202
+ # Create stage directories based on global configuration
203
+ for stage in CURRICULUM_STAGES:
204
+ stage_dir = os.path.join(model_dir, stage)
205
+ os.makedirs(stage_dir, exist_ok=True)
206
+ os.makedirs(os.path.join(stage_dir, "checkpoints"), exist_ok=True)
207
+ os.makedirs(os.path.join(stage_dir, "results"), exist_ok=True)
208
+
209
+ def _get_optimizer(
210
+ self,
211
+ batch_size: int = None,
212
+ lr_encoder: float = None,
213
+ lr_projector: float = None,
214
+ lr_base: float = None,
215
+ ):
216
+ """Get optimizer for the model with configurable learning rates."""
217
+ # Get the underlying model (handles DDP wrapping)
218
+ model = self._get_model()
219
+
220
+ if self.model_type == "OpenTSLMSP":
221
+ # Parameter groups with different learning rates for SP
222
+ enc_params = list(model.encoder.parameters())
223
+ proj_params = list(model.projector.projector.parameters())
224
+
225
+ # Use provided learning rates or defaults
226
+ encoder_lr = lr_encoder if lr_encoder is not None else LR_ENCODER
227
+ projector_lr = lr_projector if lr_projector is not None else LR_PROJECTOR
228
+
229
+ param_groups = [
230
+ {"params": enc_params, "lr": encoder_lr, "weight_decay": WEIGHT_DECAY},
231
+ {
232
+ "params": proj_params,
233
+ "lr": projector_lr,
234
+ "weight_decay": WEIGHT_DECAY,
235
+ },
236
+ ]
237
+
238
+ # Add LoRA parameters if enabled
239
+ if hasattr(model, "lora_enabled") and model.lora_enabled:
240
+ lora_params = model.get_lora_parameters()
241
+ if lora_params:
242
+ # Use projector LR for LoRA parameters (similar fine-tuning nature)
243
+ param_groups.append(
244
+ {
245
+ "params": lora_params,
246
+ "lr": projector_lr,
247
+ "weight_decay": WEIGHT_DECAY,
248
+ }
249
+ )
250
+ if self.rank == 0:
251
+ print(f"📊 Learning rates for {self.model_type} (with LoRA):")
252
+ print(f" Encoder LR: {encoder_lr:.2e}")
253
+ print(f" Projector LR: {projector_lr:.2e}")
254
+ print(
255
+ f" LoRA LR: {projector_lr:.2e} ({len(lora_params)} parameters)"
256
+ )
257
+ else:
258
+ raise RuntimeError(
259
+ "LoRA is enabled but no trainable LoRA parameters found. This indicates a LoRA configuration issue."
260
+ )
261
+ else:
262
+ if self.rank == 0:
263
+ print(f"📊 Learning rates for {self.model_type}:")
264
+ print(f" Encoder LR: {encoder_lr:.2e}")
265
+ print(f" Projector LR: {projector_lr:.2e}")
266
+
267
+ return AdamW(param_groups)
268
+ else:
269
+ # For Flamingo, use grouped parameters
270
+ params_to_optimize = model.named_parameters()
271
+ params_to_optimize = list(
272
+ filter(
273
+ lambda x: x[1].requires_grad
274
+ and not getattr(x[1], "exclude_from_optimizer", False),
275
+ params_to_optimize,
276
+ )
277
+ )
278
+
279
+ # Group parameters for weight decay
280
+ params_with_wd, params_without_wd = [], []
281
+ for n, p in params_to_optimize:
282
+ if "gated_cross_attn" in n:
283
+ params_with_wd.append(p)
284
+ else:
285
+ params_without_wd.append(p)
286
+
287
+ # Use provided base learning rate or default
288
+ base_lr = lr_base if lr_base is not None else 2e-4
289
+
290
+ if self.rank == 0:
291
+ print(f"📊 Learning rate for {self.model_type}:")
292
+ print(f" Base LR: {base_lr:.2e}")
293
+
294
+ return torch.optim.AdamW(
295
+ [
296
+ {"params": params_with_wd, "weight_decay": 0.1},
297
+ {"params": params_without_wd, "weight_decay": 0.0},
298
+ ],
299
+ lr=base_lr,
300
+ )
301
+
302
+ def _merge_data_loaders(
303
+ self,
304
+ datasets: List[Dataset],
305
+ shuffle: bool,
306
+ batch_size: int,
307
+ patch_size: int,
308
+ distribute_data: bool = False,
309
+ ) -> DataLoader:
310
+ """Create a merged data loader from multiple datasets."""
311
+ merged_ds = ConcatDataset(datasets)
312
+
313
+ # Use distributed sampler if distributed training is enabled
314
+ if distribute_data and dist.is_initialized():
315
+ sampler = DistributedSampler(
316
+ merged_ds, num_replicas=self.world_size, rank=self.rank, shuffle=shuffle
317
+ )
318
+ return DataLoader(
319
+ merged_ds,
320
+ sampler=sampler,
321
+ batch_size=batch_size,
322
+ collate_fn=lambda batch: extend_time_series_to_match_patch_size_and_aggregate(
323
+ batch, patch_size=patch_size
324
+ ),
325
+ )
326
+ else:
327
+ return DataLoader(
328
+ merged_ds,
329
+ shuffle=shuffle,
330
+ batch_size=batch_size,
331
+ collate_fn=lambda batch: extend_time_series_to_match_patch_size_and_aggregate(
332
+ batch, patch_size=patch_size
333
+ ),
334
+ )
335
+
336
+ def _save_checkpoint(
337
+ self, stage: str, epoch: int, val_loss: float, optimizer, scheduler
338
+ ):
339
+ """Save model checkpoint for a specific stage."""
340
+ checkpoint_dir = os.path.join(self.results_dir, stage, "checkpoints")
341
+
342
+ # Only save on rank 0 for distributed training
343
+ if dist.is_initialized() and self.rank != 0:
344
+ return
345
+
346
+ # Get the underlying model (handles DDP wrapping)
347
+ model = self._get_model()
348
+
349
+ if self.model_type == "OpenTSLMSP":
350
+ checkpoint = {
351
+ "encoder_state": model.encoder.state_dict(),
352
+ "projector_state": model.projector.state_dict(),
353
+ "optimizer_state": optimizer.state_dict(),
354
+ "scheduler_state": scheduler.state_dict(),
355
+ "val_loss": val_loss,
356
+ "epoch": epoch,
357
+ }
358
+
359
+ # Add LoRA state to checkpoint
360
+ model.save_lora_state_to_checkpoint(checkpoint)
361
+ else:
362
+ # Handle DDP or single GPU case for OpenTSLMFlamingo
363
+ model_state = model.state_dict()
364
+ if hasattr(self.model, "module"):
365
+ # Remove 'module.' prefix for DDP
366
+ model_state = {
367
+ k.replace("module.", ""): v for k, v in model_state.items()
368
+ }
369
+ checkpoint = {
370
+ "model_state": model_state,
371
+ "optimizer_state": optimizer.state_dict(),
372
+ "scheduler_state": scheduler.state_dict(),
373
+ "val_loss": val_loss,
374
+ "epoch": epoch,
375
+ }
376
+
377
+ checkpoint_path = os.path.join(checkpoint_dir, "best_model.pt")
378
+
379
+ # Check disk space before saving
380
+ if self.rank == 0:
381
+ import shutil
382
+
383
+ total, used, free = shutil.disk_usage(checkpoint_dir)
384
+ free_gb = free / (1024**3)
385
+ print(f"💾 Disk space: {free_gb:.2f} GB free in {checkpoint_dir}")
386
+
387
+ # Estimate checkpoint size (rough estimate)
388
+ estimated_size_gb = sum(
389
+ p.numel() * p.element_size() for p in self._get_model().parameters()
390
+ ) / (1024**3)
391
+ if (
392
+ free_gb < estimated_size_gb * 2
393
+ ): # Need at least 2x the size for safe writing
394
+ print(
395
+ f"⚠️ Warning: Low disk space. Need ~{estimated_size_gb:.2f} GB, have {free_gb:.2f} GB free"
396
+ )
397
+
398
+ # Try to save with error handling
399
+ try:
400
+ torch.save(checkpoint, checkpoint_path)
401
+ except Exception as e:
402
+ if self.rank == 0:
403
+ print(f"❌ Failed to save checkpoint: {e}")
404
+ print(f" Checkpoint path: {checkpoint_path}")
405
+ print(
406
+ f" Checkpoint size: {sum(p.numel() * p.element_size() for p in self._get_model().parameters()) / 1024**3:.2f} GB"
407
+ )
408
+
409
+ raise RuntimeError(f"Failed to save checkpoint: {e}")
410
+
411
+ def _save_loss_history(
412
+ self, stage: str, epoch: int, train_loss: float, val_loss: float
413
+ ):
414
+ """Save loss history to a file for tracking training progress."""
415
+ if dist.is_initialized() and self.rank != 0:
416
+ return # Only save on rank 0 for distributed training
417
+
418
+ checkpoint_dir = os.path.join(self.results_dir, stage, "checkpoints")
419
+ loss_history_file = os.path.join(checkpoint_dir, "loss_history.txt")
420
+
421
+ # Ensure the directory exists
422
+ os.makedirs(checkpoint_dir, exist_ok=True)
423
+
424
+ # Create the file with header if it doesn't exist
425
+ if not os.path.exists(loss_history_file):
426
+ with open(loss_history_file, "w") as f:
427
+ f.write("Epoch\tTrain_Loss\tVal_Loss\n")
428
+ f.write("-" * 30 + "\n")
429
+
430
+ # Append the current epoch's losses
431
+ with open(loss_history_file, "a") as f:
432
+ f.write(f"{epoch}\t{train_loss:.6f}\t{val_loss:.6f}\n")
433
+
434
+ def _display_loss_history(self, stage: str):
435
+ """Display the loss history for a stage if available."""
436
+ if dist.is_initialized() and self.rank != 0:
437
+ return # Only display on rank 0 for distributed training
438
+
439
+ checkpoint_dir = os.path.join(self.results_dir, stage, "checkpoints")
440
+ loss_history_file = os.path.join(checkpoint_dir, "loss_history.txt")
441
+
442
+ if os.path.exists(loss_history_file):
443
+ try:
444
+ with open(loss_history_file, "r") as f:
445
+ lines = f.readlines()
446
+
447
+ if len(lines) > 2: # More than just header
448
+ print(f"📊 Previous loss history for {stage}:")
449
+ print(" Epoch\tTrain_Loss\tVal_Loss")
450
+ print(" " + "-" * 30)
451
+
452
+ # Show last 5 epochs (or all if less than 5)
453
+ start_idx = max(2, len(lines) - 5) # Skip header lines
454
+ for line in lines[start_idx:]:
455
+ if line.strip() and not line.startswith("-"):
456
+ parts = line.strip().split("\t")
457
+ if len(parts) == 3:
458
+ epoch, train_loss, val_loss = parts
459
+ print(f" {epoch}\t{train_loss}\t{val_loss}")
460
+
461
+ if len(lines) > 7: # More than 5 epochs
462
+ print(f" ... and {len(lines) - 7} more epochs")
463
+ print()
464
+ except Exception as e:
465
+ print(f"⚠️ Could not read loss history: {e}")
466
+
467
+ def _load_checkpoint(
468
+ self, stage: str, optimizer, scheduler, eval_only: bool = False
469
+ ):
470
+ """Load model checkpoint for a specific stage."""
471
+ checkpoint_path = os.path.join(
472
+ self.results_dir, stage, "checkpoints", "best_model.pt"
473
+ )
474
+
475
+ if os.path.exists(checkpoint_path):
476
+ # Always load checkpoint to CPU first to avoid GPU OOM spikes
477
+ checkpoint = torch.load(
478
+ checkpoint_path, map_location="cpu", weights_only=False
479
+ )
480
+
481
+ # Get the underlying model (handles DDP wrapping)
482
+ model = self._get_model()
483
+
484
+ if self.model_type == "OpenTSLMSP":
485
+ model.encoder.load_state_dict(checkpoint["encoder_state"])
486
+ model.projector.load_state_dict(checkpoint["projector_state"])
487
+
488
+ # Load LoRA state using the OpenTSLMSP method (allow missing for backward compatibility)
489
+ try:
490
+ model.load_lora_state_from_checkpoint(
491
+ checkpoint, allow_missing=True
492
+ )
493
+ except RuntimeError as e:
494
+ if self.rank == 0:
495
+ print(f"❌ Failed to load LoRA state from checkpoint: {e}")
496
+ raise
497
+
498
+ # Only load optimizer state when training
499
+ if (
500
+ not eval_only
501
+ and optimizer is not None
502
+ and "optimizer_state" in checkpoint
503
+ ):
504
+ optimizer.load_state_dict(checkpoint["optimizer_state"])
505
+ else:
506
+ # Handle DDP or single GPU case for OpenTSLMFlamingo
507
+ model_state = checkpoint["model_state"]
508
+ if hasattr(self.model, "module"):
509
+ # Add 'module.' prefix for DDP
510
+ model_state = {f"module.{k}": v for k, v in model_state.items()}
511
+
512
+ # Load state dict with strict=False to handle missing keys
513
+ try:
514
+ missing_keys, unexpected_keys = self.model.load_state_dict(
515
+ model_state, strict=False
516
+ )
517
+ if missing_keys and self.rank == 0:
518
+ print(
519
+ f"⚠️ Warning: Missing keys when loading checkpoint for {stage}:"
520
+ )
521
+ for key in missing_keys[:10]: # Show first 10 missing keys
522
+ print(f" - {key}")
523
+ if len(missing_keys) > 10:
524
+ print(f" ... and {len(missing_keys) - 10} more keys")
525
+ if unexpected_keys and self.rank == 0:
526
+ print(
527
+ f"⚠️ Warning: Unexpected keys when loading checkpoint for {stage}:"
528
+ )
529
+ for key in unexpected_keys[
530
+ :10
531
+ ]: # Show first 10 unexpected keys
532
+ print(f" - {key}")
533
+ if len(unexpected_keys) > 10:
534
+ print(f" ... and {len(unexpected_keys) - 10} more keys")
535
+ except Exception as e:
536
+ raise RuntimeError(
537
+ f"Failed to load model state from checkpoint for {stage}: {e}"
538
+ )
539
+
540
+ # Only load optimizer state when training
541
+ if (
542
+ not eval_only
543
+ and optimizer is not None
544
+ and "optimizer_state" in checkpoint
545
+ ):
546
+ optimizer.load_state_dict(checkpoint["optimizer_state"])
547
+
548
+ # Only load scheduler state when training
549
+ if (
550
+ not eval_only
551
+ and scheduler is not None
552
+ and "scheduler_state" in checkpoint
553
+ ):
554
+ scheduler.load_state_dict(checkpoint["scheduler_state"])
555
+
556
+ return checkpoint.get("epoch", "?"), checkpoint.get(
557
+ "val_loss", float("inf")
558
+ )
559
+ return None, float("inf")
560
+
561
+ def _load_previous_stage_model(
562
+ self, current_stage: str
563
+ ) -> Optional[Dict[str, Any]]:
564
+ """Load the best model from the previous stage and return its metrics."""
565
+ try:
566
+ current_idx = CURRICULUM_STAGES.index(current_stage)
567
+ if current_idx == 0:
568
+ # First stage, no previous model to load
569
+ return None
570
+ previous_stage = CURRICULUM_STAGES[current_idx - 1]
571
+ metrics_file = os.path.join(
572
+ self.results_dir, previous_stage, "results", "metrics.json"
573
+ )
574
+ if not os.path.exists(metrics_file):
575
+ # PATCH: If running stage2_captioning and previous stage metrics are missing, skip loading
576
+ if current_stage == "stage2_captioning":
577
+ if self.rank == 0:
578
+ print(
579
+ f"⚠️ Skipping previous stage {previous_stage} because metrics file not found: {metrics_file}"
580
+ )
581
+ return None
582
+ raise RuntimeError(
583
+ f"Previous stage {previous_stage} metrics file not found: {metrics_file}"
584
+ )
585
+ # Be robust to malformed JSON (e.g., concurrent writes or concatenated JSON)
586
+ try:
587
+ with open(metrics_file, "r") as f:
588
+ metrics = json.load(f)
589
+ except Exception as e:
590
+ if self.rank == 0:
591
+ print(
592
+ f"⚠️ Warning: Could not parse metrics file for {previous_stage} ({metrics_file}): {e}"
593
+ )
594
+ print(" Proceeding without previous metrics.")
595
+ metrics = {}
596
+ # Load the model weights from previous stage
597
+ checkpoint_path = os.path.join(
598
+ self.results_dir, previous_stage, "checkpoints", "best_model.pt"
599
+ )
600
+ if not os.path.exists(checkpoint_path):
601
+ # PATCH: If running stage2_captioning and previous stage checkpoint is missing, skip loading
602
+ if current_stage == "stage2_captioning":
603
+ if self.rank == 0:
604
+ print(
605
+ f"⚠️ Skipping previous stage {previous_stage} because checkpoint not found: {checkpoint_path}"
606
+ )
607
+ return None
608
+ raise RuntimeError(
609
+ f"Previous stage {previous_stage} checkpoint not found: {checkpoint_path}"
610
+ )
611
+ print(
612
+ "Loading checkpoint from previous stage: ",
613
+ checkpoint_path,
614
+ "and model type: ",
615
+ self.model_type,
616
+ "and llm_id: ",
617
+ self.llm_id,
618
+ )
619
+ print("This might take a while")
620
+ checkpoint = torch.load(
621
+ checkpoint_path, map_location="cpu", weights_only=False
622
+ )
623
+ # Get the underlying model (handles DDP wrapping)
624
+ model = self._get_model()
625
+ if self.model_type == "OpenTSLMSP":
626
+ model.encoder.load_state_dict(checkpoint["encoder_state"])
627
+ model.projector.load_state_dict(checkpoint["projector_state"])
628
+
629
+ # Load LoRA state from previous stage (allow missing for stage transitions)
630
+ try:
631
+ loaded_count = model.load_lora_state_from_checkpoint(
632
+ checkpoint, allow_missing=True
633
+ )
634
+ if loaded_count > 0 and self.rank == 0:
635
+ print(
636
+ f"📥 Loaded LoRA adapters from previous stage: {loaded_count} parameters"
637
+ )
638
+ except RuntimeError as e:
639
+ if self.rank == 0:
640
+ print(f"❌ Failed to load LoRA state from previous stage: {e}")
641
+ # For previous stage loading, we can be more tolerant of LoRA mismatches
642
+ # as stages might have different LoRA configurations
643
+ else:
644
+ # Handle OpenTSLMFlamingo with graceful loading
645
+ model_state = checkpoint["model_state"]
646
+ if hasattr(self.model, "module"):
647
+ # Add 'module.' prefix for DDP
648
+ model_state = {f"module.{k}": v for k, v in model_state.items()}
649
+ # Load state dict with strict=False to handle missing keys
650
+ try:
651
+ missing_keys, unexpected_keys = self.model.load_state_dict(
652
+ model_state, strict=False
653
+ )
654
+ if missing_keys and self.rank == 0:
655
+ print(
656
+ f"⚠️ Warning: Missing keys when loading previous stage {previous_stage}:"
657
+ )
658
+ for key in missing_keys[:5]: # Show first 5 missing keys
659
+ print(f" - {key}")
660
+ if len(missing_keys) > 5:
661
+ print(f" ... and {len(missing_keys) - 5} more keys")
662
+ print(
663
+ f" This is normal when transitioning between stages with different model configurations."
664
+ )
665
+ if unexpected_keys and self.rank == 0:
666
+ print(
667
+ f"⚠️ Warning: Unexpected keys when loading previous stage {previous_stage}:"
668
+ )
669
+ for key in unexpected_keys[:5]: # Show first 5 unexpected keys
670
+ print(f" - {key}")
671
+ if len(unexpected_keys) > 5:
672
+ print(f" ... and {len(unexpected_keys) - 5} more keys")
673
+ except Exception as e:
674
+ raise RuntimeError(
675
+ f"Failed to load model state from previous stage {previous_stage}: {e}"
676
+ )
677
+ return {
678
+ "stage": previous_stage,
679
+ "metrics": metrics,
680
+ "epoch": checkpoint.get("epoch", "?"),
681
+ "val_loss": checkpoint.get("val_loss", "?"),
682
+ }
683
+ except Exception as e:
684
+ raise RuntimeError(f"Failed to load previous stage model: {e}")
685
+
686
+ def _calculate_accuracy(
687
+ self, predictions: List[str], gold_answers: List[str]
688
+ ) -> float:
689
+ """Calculate accuracy for MCQ tasks."""
690
+ correct = 0
691
+ total = len(predictions)
692
+
693
+ for pred, gold in zip(predictions, gold_answers):
694
+ # Clean up predictions and gold answers
695
+ pred_clean = pred.strip()
696
+ gold_clean = gold.strip()
697
+
698
+ # Check if gold starts with the cleaned prediction (more robust matching)
699
+ if gold_clean.startswith(pred_clean) or pred_clean == gold_clean:
700
+ correct += 1
701
+
702
+ return correct / total if total > 0 else 0.0
703
+
704
+ def _evaluate_stage(
705
+ self,
706
+ stage: str,
707
+ test_loader: DataLoader,
708
+ stage_name: str,
709
+ metric_func: Callable = None,
710
+ epoch: int = None,
711
+ ) -> Dict[str, Any]:
712
+ """Evaluate model on test set for a specific stage."""
713
+ # Enable eval mode for all ranks
714
+ self.model.eval()
715
+ results = []
716
+ test_loss = 0.0
717
+
718
+ # Generation budget: MCQ answers are a few tokens and captions are short
719
+ # (M4 reference ~180 tokens, max ~220), so cap these stages at 256. This
720
+ # stops verbose backbones (e.g. Gemma) from generating to the full budget
721
+ # without truncating any real output. CoT stages keep 2000 for long rationales.
722
+ if "captioning" in stage_name or "mcq" in stage_name:
723
+ max_new_tokens = 256
724
+ else:
725
+ max_new_tokens = 2000
726
+
727
+ # Prepare per-rank streaming writer for test predictions
728
+ results_file_rank = os.path.join(
729
+ self.results_dir,
730
+ stage_name,
731
+ "results",
732
+ f"test_predictions_rank_{self.rank if dist.is_initialized() else 0}.jsonl",
733
+ )
734
+ final_results_file = os.path.join(
735
+ self.results_dir, stage_name, "results", "test_predictions.jsonl"
736
+ )
737
+ results_fp = None
738
+ # Ensure directory exists (defensive)
739
+ os.makedirs(os.path.dirname(results_file_rank), exist_ok=True)
740
+ if self.rank == 0:
741
+ print(f"[Eval] rank={self.rank}, world_size={self.world_size}")
742
+ print(f"Saving per-rank test predictions to: {results_file_rank}")
743
+ if dist.is_initialized():
744
+ print(
745
+ f"Final merged predictions will be saved to: {final_results_file}"
746
+ )
747
+ # Open per-rank file in write mode to start fresh, then append per-sample
748
+ results_fp = open(results_file_rank, "w", encoding="utf-8")
749
+ if not results_fp:
750
+ raise RuntimeError(
751
+ f"Failed to open per-rank results file: {results_file_rank}"
752
+ )
753
+ try:
754
+ with torch.no_grad():
755
+ for batch in tqdm(
756
+ test_loader, desc=f"Evaluating {stage_name}", disable=self.rank != 0
757
+ ):
758
+ # Generate predictions with higher max_tokens (skip separate loss computation)
759
+ predictions = self._get_model().generate(
760
+ batch, max_new_tokens=max_new_tokens
761
+ )
762
+
763
+ # Collect results
764
+ for sample, pred in zip(batch, predictions):
765
+ result = {
766
+ "pre_prompt": sample["pre_prompt"],
767
+ "time_series_text": sample["time_series_text"],
768
+ "post_prompt": sample["post_prompt"],
769
+ "generated": pred,
770
+ "gold": sample["answer"],
771
+ }
772
+
773
+ # Add time series ID for stage2 captioning
774
+ if stage == "stage2_captioning" and "id" in sample:
775
+ result["time_series_id"] = sample["id"]
776
+
777
+ # Add template_id and ecg_id for stage5_ecg_cot
778
+ if stage == "stage5_ecg_cot":
779
+ if "template_id" in sample:
780
+ result["template_id"] = sample["template_id"]
781
+ if "ecg_id" in sample:
782
+ result["ecg_id"] = sample["ecg_id"]
783
+ if "correct_answer" in sample:
784
+ result["correct_answer"] = sample["correct_answer"]
785
+ results.append(result)
786
+ # Stream write each result immediately to per-rank file
787
+ results_fp.write(json.dumps(result, ensure_ascii=False) + "\n")
788
+ results_fp.flush()
789
+ try:
790
+ os.fsync(results_fp.fileno())
791
+ except Exception:
792
+ pass
793
+ finally:
794
+ if results_fp is not None:
795
+ results_fp.close()
796
+
797
+ # Synchronize all ranks before merging
798
+ if dist.is_initialized():
799
+ dist.barrier()
800
+
801
+ # Rank 0 merges per-rank files into final results file
802
+ if (not dist.is_initialized()) or (self.rank == 0):
803
+ try:
804
+ # Overwrite final file each evaluation
805
+ with open(final_results_file, "w", encoding="utf-8") as merged_fp:
806
+ if dist.is_initialized():
807
+ num_ranks = self.world_size
808
+ else:
809
+ num_ranks = 1
810
+ for r in range(num_ranks):
811
+ part_file = os.path.join(
812
+ self.results_dir,
813
+ stage_name,
814
+ "results",
815
+ f"test_predictions_rank_{r}.jsonl",
816
+ )
817
+ if os.path.exists(part_file):
818
+ with open(part_file, "r", encoding="utf-8") as pf:
819
+ for line in pf:
820
+ merged_fp.write(line)
821
+ if self.rank == 0:
822
+ print(f"Merged per-rank predictions into: {final_results_file}")
823
+ finally:
824
+ pass
825
+ avg_test_loss = float("nan")
826
+ # Calculate stage-specific metrics
827
+ metrics = {"test_loss": avg_test_loss}
828
+ if epoch is not None:
829
+ metrics["epoch"] = epoch
830
+ if metric_func:
831
+ # Compute metrics on rank 0 after merging, else minimal metrics
832
+ if (not dist.is_initialized()) or (self.rank == 0):
833
+ predictions = []
834
+ gold_answers = []
835
+ # Read from final merged file
836
+ merged_path = final_results_file
837
+ with open(merged_path, "r", encoding="utf-8") as f:
838
+ for line in f:
839
+ try:
840
+ obj = json.loads(line)
841
+ predictions.append(obj.get("generated", ""))
842
+ gold_answers.append(obj.get("gold", ""))
843
+ except Exception:
844
+ continue
845
+ additional_metrics = metric_func(predictions, gold_answers)
846
+ metrics.update(additional_metrics)
847
+ # Save results only on rank 0 (or when not distributed)
848
+ if (not dist.is_initialized()) or (self.rank == 0):
849
+ # Save metrics
850
+ metrics_file = os.path.join(
851
+ self.results_dir, stage_name, "results", "metrics.json"
852
+ )
853
+ with open(metrics_file, "w") as f:
854
+ json.dump(metrics, f, indent=2)
855
+
856
+ print(f"✅ {stage_name} evaluation complete:")
857
+ print(f" Test predictions saved to: {final_results_file}")
858
+ print(f" Metrics saved to: {metrics_file}")
859
+ print(f" Max tokens used for generation: {max_new_tokens}")
860
+ for metric, value in metrics.items():
861
+ if isinstance(value, (int, float)):
862
+ print(f" {metric}: {value:.4f}")
863
+ else:
864
+ print(f" {metric}: {value}")
865
+
866
+ # Signal other ranks that evaluation is complete
867
+ if dist.is_initialized():
868
+ dist.barrier()
869
+
870
+ return metrics
871
+
872
+ def _is_evaluation_completed(self, stage: str) -> bool:
873
+ """Check if evaluation was completed for a stage by looking for test predictions file."""
874
+ test_predictions_file = os.path.join(
875
+ self.results_dir, stage, "results", "test_predictions.jsonl"
876
+ )
877
+ metrics_file = os.path.join(self.results_dir, stage, "results", "metrics.json")
878
+
879
+ # Check if both files exist
880
+ if not os.path.exists(test_predictions_file) or not os.path.exists(
881
+ metrics_file
882
+ ):
883
+ return False
884
+
885
+ # Also check if metrics file has evaluation results
886
+ try:
887
+ with open(metrics_file, "r") as f:
888
+ metrics = json.load(f)
889
+ return "test_loss" in metrics
890
+ except:
891
+ return False
892
+
893
+ def _train_stage(
894
+ self,
895
+ stage_name: str,
896
+ dataset_class,
897
+ num_epochs: int,
898
+ lr_encoder: float,
899
+ lr_projector: float,
900
+ lr_base: float,
901
+ metric_func: Callable = None,
902
+ batch_size: int = None,
903
+ eval_only: bool = False,
904
+ sampler=None,
905
+ ) -> Dict[str, Any]:
906
+ """Generic training function for any stage."""
907
+ epoch = None
908
+ # Use provided batch_size or default to global BATCH_SIZE
909
+ if batch_size is None:
910
+ batch_size = BATCH_SIZE
911
+
912
+ if self.rank == 0:
913
+ print(f"\n🚀 Starting {stage_name} Training with {self.model_type}")
914
+ if eval_only:
915
+ print("🔍 EVAL-ONLY MODE: Skipping training, only running evaluation")
916
+ print("=" * 60)
917
+ print(f"📊 Stage Configuration:")
918
+ print(f" Epochs: {num_epochs}")
919
+ if self.model_type == "OpenTSLMSP":
920
+ print(f" Encoder LR: {lr_encoder:.2e}")
921
+ print(f" Projector LR: {lr_projector:.2e}")
922
+ else:
923
+ print(f" Base LR: {lr_base:.2e}")
924
+ print(f" Batch size per GPU: {batch_size}")
925
+ if self.world_size > 1:
926
+ print(f" Effective batch size: {batch_size * self.world_size}")
927
+ print()
928
+
929
+ # Check if checkpoint exists when in eval_only mode
930
+ if eval_only and not self._checkpoint_exists(stage_name):
931
+ raise RuntimeError(
932
+ f"Eval-only mode requires a checkpoint for {stage_name}, but none found at {os.path.join(self.results_dir, stage_name, 'checkpoints', 'best_model.pt')}"
933
+ )
934
+
935
+ # Load previous stage model and display metrics
936
+ try:
937
+ previous_stage_info = self._load_previous_stage_model(stage_name)
938
+ if previous_stage_info:
939
+ if self.rank == 0:
940
+ print(f"📂 Loading best model from {previous_stage_info['stage']}:")
941
+ print(f" Achieved at epoch: {previous_stage_info['epoch']}")
942
+ val_loss = previous_stage_info["val_loss"]
943
+ if isinstance(val_loss, (int, float)):
944
+ print(f" Validation loss: {val_loss:.4f}")
945
+ else:
946
+ print(f" Validation loss: {val_loss}")
947
+ for metric, value in previous_stage_info["metrics"].items():
948
+ if isinstance(value, (int, float)):
949
+ print(f" {metric}: {value:.4f}")
950
+ else:
951
+ print(f" {metric}: {value}")
952
+ print()
953
+ else:
954
+ # Only allow fresh model for first stage
955
+ if stage_name != CURRICULUM_STAGES[0]:
956
+ raise RuntimeError(
957
+ f"Cannot start {stage_name} with fresh model. Previous stage {CURRICULUM_STAGES[CURRICULUM_STAGES.index(stage_name) - 1]} must be completed first."
958
+ )
959
+ if self.rank == 0:
960
+ print("🆕 Starting with fresh model (first stage)")
961
+ print()
962
+ except Exception as e:
963
+ if self.rank == 0:
964
+ print(f"❌ Error loading previous stage: {e}")
965
+ raise Exception(f"Error loading previous stage: {e}")
966
+
967
+ # Check if evaluation was already completed
968
+ evaluation_completed = self._is_evaluation_completed(stage_name)
969
+ if evaluation_completed and self.rank == 0:
970
+ print(
971
+ f"✅ Evaluation already completed for {stage_name}, skipping training and evaluation"
972
+ )
973
+ print(f"📂 Loading existing metrics...")
974
+
975
+ # Load and return existing metrics
976
+ metrics_file = os.path.join(
977
+ self.results_dir, stage_name, "results", "metrics.json"
978
+ )
979
+ with open(metrics_file, "r") as f:
980
+ metrics = json.load(f)
981
+
982
+ print(f"📊 Existing results for {stage_name}:")
983
+ for metric, value in metrics.items():
984
+ if isinstance(value, (int, float)):
985
+ print(f" {metric}: {value:.4f}")
986
+ else:
987
+ print(f" {metric}: {value}")
988
+
989
+ return metrics
990
+
991
+ # Enable LoRA if needed for this stage
992
+ self._enable_lora_if_needed(stage_name)
993
+
994
+ # Initialize optimizer and scheduler
995
+ optimizer = self._get_optimizer(batch_size, lr_encoder, lr_projector, lr_base)
996
+
997
+ # Create data loaders
998
+ if sampler is not None:
999
+ if self.world_size > 1:
1000
+ get_logger().warning(
1001
+ "BalancedBatchSampler was provided, but distributed training (DDP) is enabled. BalancedBatchSampler will NOT be used. Data will be sharded using DistributedSampler instead. Typically for stage3_cot it is better to use BalancedBatchSampler, if dataset is imbalanced."
1002
+ )
1003
+ train_loader = self._merge_data_loaders(
1004
+ [
1005
+ dataset_class(
1006
+ "train", EOS_TOKEN=self._get_model().get_eos_token()
1007
+ )
1008
+ ],
1009
+ shuffle=True,
1010
+ batch_size=batch_size,
1011
+ patch_size=PATCH_SIZE,
1012
+ distribute_data=True,
1013
+ )
1014
+ else:
1015
+ train_dataset = dataset_class(
1016
+ "train", EOS_TOKEN=self._get_model().get_eos_token()
1017
+ )
1018
+ train_loader = DataLoader(
1019
+ train_dataset,
1020
+ batch_sampler=sampler,
1021
+ collate_fn=lambda batch: extend_time_series_to_match_patch_size_and_aggregate(
1022
+ batch, patch_size=PATCH_SIZE
1023
+ ),
1024
+ )
1025
+ else:
1026
+ train_loader = self._merge_data_loaders(
1027
+ [dataset_class("train", EOS_TOKEN=self._get_model().get_eos_token())],
1028
+ shuffle=True,
1029
+ batch_size=batch_size,
1030
+ patch_size=PATCH_SIZE,
1031
+ distribute_data=self.world_size > 1,
1032
+ )
1033
+
1034
+ val_loader = self._merge_data_loaders(
1035
+ [dataset_class("validation", EOS_TOKEN=self._get_model().get_eos_token())],
1036
+ shuffle=False,
1037
+ batch_size=1,
1038
+ patch_size=PATCH_SIZE,
1039
+ distribute_data=False, # Don't distribute validation
1040
+ )
1041
+
1042
+ test_loader = self._merge_data_loaders(
1043
+ [dataset_class("test", EOS_TOKEN=self._get_model().get_eos_token())],
1044
+ shuffle=False,
1045
+ # Test-set generation eval batch size. Default 1 (original behavior).
1046
+ # Set EVAL_BATCH_SIZE>1 to batch generation (left-padded in OpenTSLMSP.generate)
1047
+ # for a large speedup; validated to match batch=1 greedy output.
1048
+ batch_size=int(os.environ.get("EVAL_BATCH_SIZE", "1")),
1049
+ patch_size=PATCH_SIZE,
1050
+ distribute_data=self.world_size > 1,
1051
+ )
1052
+
1053
+ # Scheduler
1054
+ total_steps = num_epochs * len(train_loader)
1055
+ warmup_steps = int(WARMUP_FRAC * total_steps)
1056
+ scheduler = get_linear_schedule_with_warmup(
1057
+ optimizer,
1058
+ num_warmup_steps=warmup_steps,
1059
+ num_training_steps=total_steps,
1060
+ )
1061
+
1062
+ if self.rank == 0:
1063
+ print(f"📈 Total training steps: {total_steps}")
1064
+ print(f"🔥 Warmup steps: {warmup_steps}")
1065
+
1066
+ # Load previous checkpoint if exists (for resuming current stage)
1067
+ best_epoch, best_val_loss = self._load_checkpoint(
1068
+ stage_name, optimizer, scheduler, eval_only=eval_only
1069
+ )
1070
+ if best_epoch is not None:
1071
+ print(
1072
+ f"📂 Resuming {stage_name} from epoch {best_epoch} (val_loss: {best_val_loss:.4f})"
1073
+ )
1074
+ # Display previous loss history if available
1075
+ self._display_loss_history(stage_name)
1076
+ else:
1077
+ print(f"🆕 Starting fresh training for {stage_name}")
1078
+ best_val_loss = float("inf") # Ensure proper initialization
1079
+
1080
+ # Skip training loop if eval_only is True
1081
+ if eval_only:
1082
+ if self.rank == 0:
1083
+ print(f"⏭️ Skipping training loop (eval_only mode)")
1084
+ print(f"📂 Using existing checkpoint for evaluation")
1085
+ epoch = best_epoch
1086
+ epochs_no_improve = 0
1087
+ else:
1088
+ # Training loop
1089
+ epochs_no_improve = 0
1090
+ start_epoch = best_epoch + 1 if best_epoch is not None else 1
1091
+ for epoch in range(start_epoch, num_epochs + 1):
1092
+ # Set epoch for distributed sampler
1093
+ if hasattr(train_loader.sampler, "set_epoch"):
1094
+ train_loader.sampler.set_epoch(epoch)
1095
+
1096
+ # Training
1097
+ self.model.train()
1098
+ running_loss = 0.0
1099
+ prog = tqdm(
1100
+ train_loader,
1101
+ desc=f"Epoch {epoch}/{num_epochs}",
1102
+ disable=self.rank != 0,
1103
+ )
1104
+ for i, batch in enumerate(prog):
1105
+ # DEBUG PRINT: Only for the first batch of the first epoch
1106
+ if epoch == start_epoch and i == 0:
1107
+ print(f"[DEBUG] Batch {i} - batch size: {len(batch)}")
1108
+ if isinstance(batch, list) and isinstance(batch[0], dict):
1109
+ for k, v in batch[0].items():
1110
+ if hasattr(v, "shape"):
1111
+ print(f"[DEBUG] Sample key '{k}' shape: {v.shape}")
1112
+ elif isinstance(v, list):
1113
+ print(
1114
+ f"[DEBUG] Sample key '{k}' list length: {len(v)}"
1115
+ )
1116
+ import torch
1117
+
1118
+ print(
1119
+ torch.cuda.memory_summary()
1120
+ if torch.cuda.is_available()
1121
+ else "No CUDA"
1122
+ )
1123
+ optimizer.zero_grad()
1124
+ loss = self._get_model().compute_loss(batch)
1125
+ loss.backward()
1126
+
1127
+ # Handle gradient clipping for distributed training
1128
+ clip_grad_norm_(self._get_model().parameters(), GRAD_CLIP_NORM)
1129
+
1130
+ optimizer.step()
1131
+ scheduler.step()
1132
+
1133
+ running_loss += loss.item()
1134
+ if self.rank == 0:
1135
+ prog.set_postfix(
1136
+ loss=f"{loss.item():.4f}",
1137
+ lr=f"{scheduler.get_last_lr()[0]:.2e}",
1138
+ )
1139
+
1140
+ avg_train_loss = running_loss / len(train_loader)
1141
+ if self.rank == 0:
1142
+ tqdm.write(f"Epoch {epoch} — train loss: {avg_train_loss:.4f}")
1143
+
1144
+ # Validation
1145
+ val_loss = 0.0
1146
+ self.model.eval()
1147
+ with torch.no_grad():
1148
+ for batch in tqdm(
1149
+ val_loader,
1150
+ desc=f"Validating {stage_name}",
1151
+ disable=self.rank != 0,
1152
+ ):
1153
+ val_loss += self._get_model().compute_loss(batch).item()
1154
+
1155
+ avg_val_loss = val_loss / len(val_loader)
1156
+
1157
+ # Synchronize validation loss across all ranks
1158
+ if dist.is_initialized():
1159
+ val_loss_tensor = torch.tensor(avg_val_loss, device=self.device)
1160
+ dist.all_reduce(val_loss_tensor, op=dist.ReduceOp.SUM)
1161
+ avg_val_loss = val_loss_tensor.item() / self.world_size
1162
+
1163
+ if self.rank == 0:
1164
+ tqdm.write(f"Epoch {epoch} — val loss: {avg_val_loss:.4f}")
1165
+ tqdm.write(f"Epoch {epoch} — best loss: {best_val_loss:.4f}")
1166
+
1167
+ # Save loss history for this epoch
1168
+ self._save_loss_history(stage_name, epoch, avg_train_loss, avg_val_loss)
1169
+
1170
+ # Early stopping - all ranks need to make the same decision
1171
+ should_save = avg_val_loss + 1e-4 < best_val_loss
1172
+ if dist.is_initialized():
1173
+ save_tensor = torch.tensor(
1174
+ 1 if should_save else 0, device=self.device
1175
+ )
1176
+ dist.all_reduce(save_tensor, op=dist.ReduceOp.SUM)
1177
+ should_save = (
1178
+ save_tensor.item() > 0
1179
+ ) # If any rank thinks we should save, we save
1180
+
1181
+ if should_save:
1182
+ best_val_loss = avg_val_loss
1183
+ epochs_no_improve = 0
1184
+ self._save_checkpoint(
1185
+ stage_name, epoch, avg_val_loss, optimizer, scheduler
1186
+ )
1187
+ if self.rank == 0:
1188
+ tqdm.write("✔️ New best model saved.\n")
1189
+ else:
1190
+ epochs_no_improve += 1
1191
+ if self.rank == 0:
1192
+ tqdm.write(
1193
+ f"No improvement for {epochs_no_improve}/{EARLY_STOP_PAT} epochs.\n"
1194
+ )
1195
+
1196
+ # Synchronize early stopping decision across all ranks
1197
+ if epochs_no_improve >= EARLY_STOP_PAT:
1198
+ if self.rank == 0:
1199
+ tqdm.write(
1200
+ f"\nEarly stopping triggered after {epoch} epochs."
1201
+ )
1202
+ tqdm.write(
1203
+ f"Final stats: best_val_loss={best_val_loss:.4f}, epochs_no_improve={epochs_no_improve}"
1204
+ )
1205
+ break
1206
+
1207
+ # Synchronize best_val_loss and epochs_no_improve across all ranks
1208
+ if dist.is_initialized():
1209
+ best_loss_tensor = torch.tensor(best_val_loss, device=self.device)
1210
+ epochs_tensor = torch.tensor(epochs_no_improve, device=self.device)
1211
+ dist.broadcast(best_loss_tensor, src=0)
1212
+ dist.broadcast(epochs_tensor, src=0)
1213
+ best_val_loss = best_loss_tensor.item()
1214
+ epochs_no_improve = int(epochs_tensor.item())
1215
+
1216
+ # Load best model and evaluate
1217
+ best_epoch, _ = self._load_checkpoint(stage_name, optimizer, scheduler)
1218
+ if best_epoch is not None:
1219
+ if self.rank == 0:
1220
+ print(
1221
+ f"📂 Loaded best checkpoint from epoch {best_epoch} for evaluation."
1222
+ )
1223
+
1224
+ if self.rank == 0:
1225
+ if epoch is None:
1226
+ epoch = best_epoch
1227
+ print(f"🏁 Training completed for {stage_name}")
1228
+ print(f" Total epochs run: {epoch}")
1229
+ else:
1230
+ print(f"🏁 Training completed for {stage_name}")
1231
+ print(f" Total epochs run: {epoch}")
1232
+ print(f" Best validation loss: {best_val_loss:.4f}")
1233
+ print(f" Epochs without improvement: {epochs_no_improve}")
1234
+
1235
+ metrics = self._evaluate_stage(
1236
+ stage_name, test_loader, stage_name, metric_func, best_epoch
1237
+ )
1238
+
1239
+ return metrics
1240
+
1241
+ def stage1_mcq(
1242
+ self, batch_size: int = None, eval_only: bool = False
1243
+ ) -> Dict[str, Any]:
1244
+ """Stage 1: Multiple Choice Question Answering (TSQA).
1245
+
1246
+ Configuration:
1247
+ - Epochs: 20
1248
+ - OpenTSLMSP: encoder_lr=2e-4, projector_lr=1e-4
1249
+ - OpenTSLMFlamingo: base_lr=2e-4
1250
+ - Metric: Accuracy
1251
+ """
1252
+ return self._train_stage(
1253
+ stage_name="stage1_mcq",
1254
+ dataset_class=TSQADataset,
1255
+ num_epochs=30,
1256
+ lr_encoder=2e-4,
1257
+ lr_projector=1e-4,
1258
+ lr_base=2e-4,
1259
+ metric_func=lambda preds, golds: {
1260
+ "accuracy": self._calculate_accuracy(preds, golds)
1261
+ },
1262
+ batch_size=batch_size,
1263
+ eval_only=eval_only,
1264
+ )
1265
+
1266
+ def stage2_captioning(
1267
+ self, batch_size: int = None, eval_only: bool = False
1268
+ ) -> Dict[str, Any]:
1269
+ """Stage 2: Caption Generation (M4).
1270
+
1271
+ Configuration:
1272
+ - Epochs: 15
1273
+ - OpenTSLMSP: encoder_lr=1e-4, projector_lr=5e-5 (lower for fine-tuning)
1274
+ - OpenTSLMFlamingo: base_lr=1e-4 (lower for fine-tuning)
1275
+ - Metric: Test loss only
1276
+ """
1277
+ return self._train_stage(
1278
+ stage_name="stage2_captioning",
1279
+ dataset_class=M4QADataset,
1280
+ num_epochs=20,
1281
+ lr_encoder=2e-4,
1282
+ lr_projector=1e-4,
1283
+ lr_base=2e-4,
1284
+ metric_func=None, # Only test loss for captioning
1285
+ batch_size=batch_size,
1286
+ eval_only=eval_only,
1287
+ )
1288
+
1289
+ def stage3_cot(
1290
+ self, batch_size: int = None, eval_only: bool = False
1291
+ ) -> Dict[str, Any]:
1292
+ """Stage CoT: Chain-of-Thought Reasoning (HAR).
1293
+
1294
+ Configuration:
1295
+ - Epochs: 100
1296
+ - OpenTSLMSP: encoder_lr=2e-4, projector_lr=1e-4
1297
+ - OpenTSLMFlamingo: base_lr=2e-4
1298
+ - Metric: Test loss only (chain-of-thought reasoning)
1299
+ """
1300
+ sampler = None
1301
+
1302
+ return self._train_stage(
1303
+ stage_name="stage3_cot",
1304
+ dataset_class=HARCoTQADataset,
1305
+ num_epochs=30,
1306
+ lr_encoder=2e-4,
1307
+ lr_projector=1e-4,
1308
+ lr_base=2e-4,
1309
+ metric_func=None, # Only test loss for chain-of-thought reasoning
1310
+ batch_size=batch_size,
1311
+ eval_only=eval_only,
1312
+ sampler=sampler,
1313
+ )
1314
+
1315
+ def stage4_sleep_cot(
1316
+ self, batch_size: int = None, eval_only: bool = False
1317
+ ) -> Dict[str, Any]:
1318
+ """Stage 4: Chain-of-Thought Reasoning (SleepEDF).
1319
+
1320
+ Configuration:
1321
+ - Epochs: 60
1322
+ - OpenTSLMSP: encoder_lr=2e-4, projector_lr=1e-4
1323
+ - OpenTSLMFlamingo: base_lr=2e-4
1324
+ - Metric: Test loss only (chain-of-thought reasoning)
1325
+ """
1326
+ sampler = None
1327
+
1328
+ return self._train_stage(
1329
+ stage_name="stage4_sleep_cot",
1330
+ dataset_class=SleepEDFCoTQADataset,
1331
+ num_epochs=60,
1332
+ lr_encoder=2e-4,
1333
+ lr_projector=1e-4,
1334
+ lr_base=2e-4,
1335
+ metric_func=None, # Only test loss for chain-of-thought reasoning
1336
+ batch_size=batch_size,
1337
+ eval_only=eval_only,
1338
+ sampler=sampler,
1339
+ )
1340
+
1341
+ def stage5_ecg_cot(
1342
+ self, batch_size: int = None, eval_only: bool = False
1343
+ ) -> Dict[str, Any]:
1344
+ """Stage 5: Chain-of-Thought Reasoning (ECG QA CoT).
1345
+
1346
+ Configuration:
1347
+ - Epochs: 60
1348
+ - OpenTSLMSP: encoder_lr=2e-4, projector_lr=1e-4
1349
+ - OpenTSLMFlamingo: base_lr=2e-4
1350
+ - Metric: Test loss only (chain-of-thought reasoning)
1351
+ """
1352
+ sampler = None
1353
+
1354
+ return self._train_stage(
1355
+ stage_name="stage5_ecg_cot",
1356
+ dataset_class=ECGQACoTQADataset,
1357
+ num_epochs=60,
1358
+ lr_encoder=2e-4,
1359
+ lr_projector=1e-4,
1360
+ lr_base=2e-4,
1361
+ metric_func=None, # Only test loss for chain-of-thought reasoning
1362
+ batch_size=batch_size,
1363
+ eval_only=eval_only,
1364
+ sampler=sampler,
1365
+ )
1366
+
1367
+ def run_curriculum(
1368
+ self, stages: List[str] = None, batch_size: int = None, eval_only: bool = False
1369
+ ):
1370
+ """Run the complete curriculum learning pipeline."""
1371
+ if stages is None:
1372
+ stages = CURRICULUM_STAGES
1373
+
1374
+ # Filter out completed stages
1375
+ incomplete_stages = []
1376
+ for stage in stages:
1377
+ if self._is_stage_completed(stage):
1378
+ if self.rank == 0:
1379
+ print(f"⏭️ Skipping completed stage: {stage}")
1380
+ else:
1381
+ incomplete_stages.append(stage)
1382
+
1383
+ if self.rank == 0:
1384
+ print(f"🎓 Starting Curriculum Learning with {self.model_type}")
1385
+ if eval_only:
1386
+ print("🔍 EVAL-ONLY MODE: Will skip training and only run evaluation")
1387
+ print(f"📊 All stages: {', '.join(stages)}")
1388
+ print(f"🔄 Incomplete stages: {', '.join(incomplete_stages)}")
1389
+ print(f"💻 Device: {self.device}")
1390
+ if batch_size:
1391
+ print(f"📦 Batch size: {batch_size}")
1392
+ if self.world_size > 1:
1393
+ print(f"🌐 Distributed training with {self.world_size} GPUs")
1394
+ print("=" * 80)
1395
+
1396
+ results = {}
1397
+
1398
+ # Run only incomplete stages
1399
+ for stage in incomplete_stages:
1400
+ # Synchronize all ranks before starting each stage
1401
+ if dist.is_initialized():
1402
+ dist.barrier()
1403
+
1404
+ if stage == "stage1_mcq":
1405
+ stage_results = self.stage1_mcq(
1406
+ batch_size=batch_size, eval_only=eval_only
1407
+ )
1408
+ results[stage] = stage_results
1409
+ self._mark_stage_completed(stage, stage_results)
1410
+ elif stage == "stage2_captioning":
1411
+ stage_results = self.stage2_captioning(
1412
+ batch_size=batch_size, eval_only=eval_only
1413
+ )
1414
+ results[stage] = stage_results
1415
+ self._mark_stage_completed(stage, stage_results)
1416
+ elif stage == "stage3_cot":
1417
+ stage_results = self.stage3_cot(
1418
+ batch_size=batch_size, eval_only=eval_only
1419
+ )
1420
+ results[stage] = stage_results
1421
+ self._mark_stage_completed(stage, stage_results)
1422
+ elif stage == "stage4_sleep_cot":
1423
+ stage_results = self.stage4_sleep_cot(
1424
+ batch_size=batch_size, eval_only=eval_only
1425
+ )
1426
+ results[stage] = stage_results
1427
+ self._mark_stage_completed(stage, stage_results)
1428
+ elif stage == "stage5_ecg_cot":
1429
+ stage_results = self.stage5_ecg_cot(
1430
+ batch_size=batch_size, eval_only=eval_only
1431
+ )
1432
+ results[stage] = stage_results
1433
+ self._mark_stage_completed(stage, stage_results)
1434
+ elif stage == "stage4_sleep_cot":
1435
+ stage_results = self.stage4_sleep_cot(
1436
+ batch_size=batch_size, eval_only=eval_only
1437
+ )
1438
+ results[stage] = stage_results
1439
+ self._mark_stage_completed(stage, stage_results)
1440
+ elif stage == "stage5_ecg_cot":
1441
+ stage_results = self.stage5_ecg_cot(
1442
+ batch_size=batch_size, eval_only=eval_only
1443
+ )
1444
+ results[stage] = stage_results
1445
+ self._mark_stage_completed(stage, stage_results)
1446
+ else:
1447
+ if self.rank == 0:
1448
+ print(f"⚠️ Unknown stage: {stage}, skipping...")
1449
+
1450
+ # Synchronize all ranks after completing each stage
1451
+ if dist.is_initialized():
1452
+ dist.barrier()
1453
+
1454
+ # Save overall results only on rank 0
1455
+ if self.rank == 0:
1456
+ overall_results_file = os.path.join(
1457
+ self.results_dir, "curriculum_results.json"
1458
+ )
1459
+ with open(overall_results_file, "w") as f:
1460
+ json.dump(results, f, indent=2)
1461
+
1462
+ print(f"\n🎉 Curriculum Learning Complete!")
1463
+ print(f"📁 All results saved to: {self.results_dir}/")
1464
+ print(f"📊 Overall results: {overall_results_file}")
1465
+
1466
+ return results
1467
+
1468
+ def _should_use_distributed(self) -> bool:
1469
+ """Check if distributed training should be used."""
1470
+ return ("WORLD_SIZE" in os.environ and int(os.environ["WORLD_SIZE"]) > 1) or (
1471
+ "LOCAL_RANK" in os.environ and int(os.environ["LOCAL_RANK"]) >= 0
1472
+ )
1473
+
1474
+ def _init_distributed(self):
1475
+ """Initialize distributed training."""
1476
+ if "WORLD_SIZE" in os.environ:
1477
+ self.world_size = int(os.environ["WORLD_SIZE"])
1478
+ if "RANK" in os.environ:
1479
+ self.rank = int(os.environ["RANK"])
1480
+ elif "LOCAL_RANK" in os.environ:
1481
+ self.rank = int(os.environ["LOCAL_RANK"])
1482
+
1483
+ # Initialize process group
1484
+ dist.init_process_group(
1485
+ backend=self.dist_backend,
1486
+ init_method=self.dist_url,
1487
+ world_size=self.world_size,
1488
+ rank=self.rank,
1489
+ timeout=datetime.timedelta(hours=999),
1490
+ )
1491
+
1492
+ # Set device for this process
1493
+ if torch.cuda.is_available():
1494
+ torch.cuda.set_device(self.local_rank)
1495
+ self.device = torch.device("cuda", self.local_rank)
1496
+
1497
+ if self.rank == 0:
1498
+ print(f"Initialized distributed training with {self.world_size} GPUs")
1499
+
1500
+ def _is_stage_completed(self, stage: str) -> bool:
1501
+ """Check if a stage is completed by verifying both training and evaluation were successful."""
1502
+ metrics_file = os.path.join(self.results_dir, stage, "results", "metrics.json")
1503
+
1504
+ if not os.path.exists(metrics_file):
1505
+ return False
1506
+
1507
+ try:
1508
+ with open(metrics_file, "r") as f:
1509
+ metrics = json.load(f)
1510
+
1511
+ # Check if the completion flag exists
1512
+ if not metrics.get("completed", False):
1513
+ return False
1514
+
1515
+ # Check if evaluation was actually completed by looking for test_loss
1516
+ if "test_loss" not in metrics:
1517
+ return False
1518
+
1519
+ # Check if test predictions file exists
1520
+ test_predictions_file = os.path.join(
1521
+ self.results_dir, stage, "results", "test_predictions.jsonl"
1522
+ )
1523
+ if not os.path.exists(test_predictions_file):
1524
+ return False
1525
+
1526
+ return True
1527
+
1528
+ except:
1529
+ return False
1530
+
1531
+ def _mark_stage_completed(self, stage: str, metrics: Dict[str, Any]):
1532
+ """Mark a stage as completed by adding completion flag to metrics."""
1533
+ metrics["completed"] = True
1534
+ metrics["completion_epoch"] = metrics.get("epoch", "?")
1535
+
1536
+ metrics_file = os.path.join(self.results_dir, stage, "results", "metrics.json")
1537
+ with open(metrics_file, "w") as f:
1538
+ json.dump(metrics, f, indent=2)
1539
+
1540
+ if self.rank == 0:
1541
+ print(f"✅ Stage {stage} marked as completed")
1542
+
1543
+ def _get_model(self):
1544
+ """Get the underlying model (handles DDP wrapping)."""
1545
+ if hasattr(self.model, "module"):
1546
+ return self.model.module
1547
+ return self.model
1548
+
1549
+ def _checkpoint_exists(self, stage: str) -> bool:
1550
+ """Check if a checkpoint exists for a specific stage."""
1551
+ checkpoint_path = os.path.join(
1552
+ self.results_dir, stage, "checkpoints", "best_model.pt"
1553
+ )
1554
+ return os.path.exists(checkpoint_path)
1555
+
1556
+ def _enable_lora_if_needed(self, stage_name: str):
1557
+ """Enable LoRA for OpenTSLMSP models in stages after stage2."""
1558
+ if self.model_type != "OpenTSLMSP":
1559
+ return # LoRA only for OpenTSLMSP
1560
+
1561
+ # Get the underlying model (handles DDP wrapping)
1562
+ model = self._get_model()
1563
+
1564
+ # Enable LoRA for stages after stage2_captioning
1565
+ stages_with_lora = ["stage3_cot", "stage4_sleep_cot", "stage5_ecg_cot"]
1566
+
1567
+ if stage_name in stages_with_lora:
1568
+ if not getattr(model, "lora_enabled", False):
1569
+ if self.rank == 0:
1570
+ print(f"🔧 Enabling LoRA for {stage_name}")
1571
+ try:
1572
+ model.enable_lora(lora_r=16, lora_alpha=32, lora_dropout=0.0)
1573
+ if self.rank == 0:
1574
+ print(f"✅ LoRA enabled for {stage_name}")
1575
+ except Exception as e:
1576
+ if self.rank == 0:
1577
+ print(f"❌ Failed to enable LoRA for {stage_name}: {e}")
1578
+ print(" Continuing without LoRA...")
1579
+ else:
1580
+ if self.rank == 0:
1581
+ print(f"✅ LoRA already enabled for {stage_name}")
1582
+ else:
1583
+ if self.rank == 0:
1584
+ if stage_name in ["stage1_mcq", "stage2_captioning"]:
1585
+ print(
1586
+ f"ℹ️ LoRA disabled for {stage_name} (only enabled for stages 3+)"
1587
+ )
1588
+ else:
1589
+ print(f"ℹ️ LoRA not configured for {stage_name}")
1590
+
1591
+ def _enable_lora_if_needed(self, stage_name: str):
1592
+ """Enable LoRA for OpenTSLMSP models in stages after stage2."""
1593
+ if self.model_type != "OpenTSLMSP":
1594
+ return # LoRA only for OpenTSLMSP
1595
+
1596
+ # Get the underlying model (handles DDP wrapping)
1597
+ model = self._get_model()
1598
+
1599
+ # Enable LoRA for stages after stage2_captioning
1600
+ stages_with_lora = ["stage3_cot", "stage4_sleep_cot", "stage5_ecg_cot"]
1601
+
1602
+ if stage_name in stages_with_lora:
1603
+ if not getattr(model, "lora_enabled", False):
1604
+ if self.rank == 0:
1605
+ print(f"🔧 Enabling LoRA for {stage_name}")
1606
+ try:
1607
+ model.enable_lora(lora_r=16, lora_alpha=32, lora_dropout=0.0)
1608
+ if self.rank == 0:
1609
+ print(f"✅ LoRA enabled for {stage_name}")
1610
+ except Exception as e:
1611
+ if self.rank == 0:
1612
+ print(f"❌ Failed to enable LoRA for {stage_name}: {e}")
1613
+ print(" Continuing without LoRA...")
1614
+ else:
1615
+ if self.rank == 0:
1616
+ print(f"✅ LoRA already enabled for {stage_name}")
1617
+ else:
1618
+ if self.rank == 0:
1619
+ if stage_name in ["stage1_mcq", "stage2_captioning"]:
1620
+ print(
1621
+ f"ℹ️ LoRA disabled for {stage_name} (only enabled for stages 3+)"
1622
+ )
1623
+ else:
1624
+ print(f"ℹ️ LoRA not configured for {stage_name}")
1625
+
1626
+
1627
+ def main():
1628
+ parser = argparse.ArgumentParser(
1629
+ description="Curriculum Learning for OpenTSLM Models"
1630
+ )
1631
+ parser.add_argument(
1632
+ "--model",
1633
+ type=str,
1634
+ choices=["OpenTSLMSP", "OpenTSLMFlamingo"],
1635
+ required=True,
1636
+ help="Model type to train",
1637
+ )
1638
+ parser.add_argument(
1639
+ "--stages",
1640
+ nargs="+",
1641
+ choices=CURRICULUM_STAGES,
1642
+ default=CURRICULUM_STAGES,
1643
+ help="Stages to run (default: all stages)",
1644
+ )
1645
+ parser.add_argument(
1646
+ "--device", type=str, default=None, help="Device to use (cuda, mps, cpu)"
1647
+ )
1648
+ parser.add_argument(
1649
+ "--batch_size",
1650
+ type=int,
1651
+ default=None,
1652
+ help="Batch size for training (default: use value from model_config.py)",
1653
+ )
1654
+
1655
+ # Evaluation arguments
1656
+ parser.add_argument(
1657
+ "--eval_only",
1658
+ default=False,
1659
+ action="store_true",
1660
+ help="Skip training and only run evaluation (requires existing checkpoint)",
1661
+ )
1662
+
1663
+ # Model-specific arguments
1664
+ parser.add_argument(
1665
+ "--llm_id",
1666
+ type=str,
1667
+ default="meta-llama/Llama-3.2-1B",
1668
+ help="LLM model ID for OpenTSLMFlamingo (e.g., 'google/medgemma-2b', 'meta-llama/Llama-3.2-1B')",
1669
+ )
1670
+
1671
+ # Distributed training arguments
1672
+ parser.add_argument(
1673
+ "--gradient_checkpointing",
1674
+ default=False,
1675
+ action="store_true",
1676
+ help="Enable gradient checkpointing",
1677
+ )
1678
+ parser.add_argument(
1679
+ "--dist_url",
1680
+ default="env://",
1681
+ type=str,
1682
+ help="URL used to set up distributed training",
1683
+ )
1684
+ parser.add_argument(
1685
+ "--dist_backend", default="nccl", type=str, help="Distributed backend"
1686
+ )
1687
+ parser.add_argument(
1688
+ "--local_rank",
1689
+ type=int,
1690
+ default=int(os.environ.get("LOCAL_RANK", 0)),
1691
+ help="Local GPU rank",
1692
+ )
1693
+
1694
+ # Logging arguments
1695
+ parser.add_argument(
1696
+ "--verbose", default=False, action="store_true", help="Enable verbose logging"
1697
+ )
1698
+
1699
+ args = parser.parse_args()
1700
+
1701
+ # Set up global logging
1702
+ set_global_verbose(args.verbose)
1703
+ logger = get_logger(verbose=args.verbose)
1704
+
1705
+ # Initialize trainer
1706
+ trainer = CurriculumTrainer(
1707
+ args.model,
1708
+ args.device,
1709
+ gradient_checkpointing=args.gradient_checkpointing,
1710
+ dist_url=args.dist_url,
1711
+ dist_backend=args.dist_backend,
1712
+ local_rank=args.local_rank,
1713
+ llm_id=args.llm_id,
1714
+ )
1715
+
1716
+ # Run curriculum
1717
+ results = trainer.run_curriculum(args.stages, args.batch_size, args.eval_only)
1718
+
1719
+ # Print summary
1720
+ logger.info("Final Results Summary:")
1721
+ logger.info("=" * 40)
1722
+ for stage, metrics in results.items():
1723
+ logger.info(f"{stage.upper()}:")
1724
+ for metric, value in metrics.items():
1725
+ if isinstance(value, (int, float)):
1726
+ logger.info(f" {metric}: {value:.4f}")
1727
+ else:
1728
+ logger.info(f" {metric}: {value}")
1729
+
1730
+
1731
+ if __name__ == "__main__":
1732
+ main()
code/OpenTSLM/data/.gitkeep ADDED
File without changes
code/OpenTSLM/demo/huggingface/01_test_hf_tsqa.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+ """
6
+ Demo script for testing TSQA (Time Series Question Answering) model from HuggingFace.
7
+
8
+ This script:
9
+ 1. Loads a pretrained model from HuggingFace Hub
10
+ 2. Loads the TSQA test dataset
11
+ 3. Generates predictions on the evaluation set
12
+ 4. Prints model outputs
13
+ """
14
+
15
+ from opentslm.model.llm.OpenTSLM import OpenTSLM
16
+ from opentslm.time_series_datasets.TSQADataset import TSQADataset
17
+ from opentslm.time_series_datasets.util import extend_time_series_to_match_patch_size_and_aggregate
18
+ from torch.utils.data import DataLoader
19
+ from opentslm.model_config import PATCH_SIZE
20
+ import torch
21
+ # Model repository ID - change this to test different models
22
+ REPO_ID = "OpenTSLM/llama-3.2-1b-tsqa-sp"
23
+
24
+ def main():
25
+ print("=" * 60)
26
+ print("TSQA Model Demo")
27
+ print("=" * 60)
28
+
29
+ # Load model from HuggingFace
30
+ print(f"\n📥 Loading model from {REPO_ID}...")
31
+ model = OpenTSLM.load_pretrained(REPO_ID, device="cuda" if torch.cuda.is_available() else "cpu")
32
+
33
+ # Create dataset
34
+ print("\n📊 Loading TSQA test dataset...")
35
+ test_dataset = TSQADataset("test", EOS_TOKEN=model.get_eos_token())
36
+
37
+ # Create data loader
38
+ test_loader = DataLoader(
39
+ test_dataset,
40
+ shuffle=False,
41
+ batch_size=1,
42
+ collate_fn=lambda batch: extend_time_series_to_match_patch_size_and_aggregate(
43
+ batch, patch_size=PATCH_SIZE
44
+ ),
45
+ )
46
+
47
+ print(f"\n🔍 Running inference on {len(test_dataset)} test samples...")
48
+ print("=" * 60)
49
+
50
+ # Iterate over evaluation set
51
+ for i, batch in enumerate(test_loader):
52
+ # Generate predictions
53
+ predictions = model.generate(batch, max_new_tokens=200)
54
+
55
+ # Print results
56
+ for sample, pred in zip(batch, predictions):
57
+ print(f"\n📝 Sample {i + 1}:")
58
+ print(f" Question: {sample.get('pre_prompt', 'N/A')}")
59
+ if 'time_series_text' in sample:
60
+ print(f" Time Series Info: {sample['time_series_text'][:100]}...")
61
+ print(f" Gold Answer: {sample.get('answer', 'N/A')}")
62
+ print(f" Model Output: {pred}")
63
+ print("-" * 60)
64
+
65
+ # Limit to first 5 samples for demo
66
+ if i >= 4:
67
+ print("\n✅ Demo complete! (Showing first 5 samples)")
68
+ break
69
+
70
+ if __name__ == "__main__":
71
+ main()
72
+
code/OpenTSLM/demo/huggingface/02_test_hf_m4.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+ """
6
+ Demo script for testing M4 (Time Series Captioning) model from HuggingFace.
7
+
8
+ This script:
9
+ 1. Loads a pretrained model from HuggingFace Hub
10
+ 2. Loads the M4 test dataset
11
+ 3. Generates predictions on the evaluation set
12
+ 4. Prints model outputs
13
+ """
14
+
15
+ from opentslm.model.llm.OpenTSLM import OpenTSLM
16
+ from opentslm.time_series_datasets.m4.M4QADataset import M4QADataset
17
+ from opentslm.time_series_datasets.util import extend_time_series_to_match_patch_size_and_aggregate
18
+ from torch.utils.data import DataLoader
19
+ from opentslm.model_config import PATCH_SIZE
20
+ import torch
21
+
22
+ # Model repository ID - change this to test different models
23
+ REPO_ID = "OpenTSLM/llama-3.2-1b-m4-sp"
24
+
25
+ def main():
26
+ print("=" * 60)
27
+ print("M4 Captioning Model Demo")
28
+ print("=" * 60)
29
+
30
+ # Load model from HuggingFace
31
+ print(f"\n📥 Loading model from {REPO_ID}...")
32
+ model = OpenTSLM.load_pretrained(REPO_ID, device="cuda" if torch.cuda.is_available() else "cpu")
33
+
34
+ # Create dataset
35
+ print("\n📊 Loading M4 test dataset...")
36
+ test_dataset = M4QADataset("test", EOS_TOKEN=model.get_eos_token())
37
+
38
+ # Create data loader
39
+ test_loader = DataLoader(
40
+ test_dataset,
41
+ shuffle=False,
42
+ batch_size=1,
43
+ collate_fn=lambda batch: extend_time_series_to_match_patch_size_and_aggregate(
44
+ batch, patch_size=PATCH_SIZE
45
+ ),
46
+ )
47
+
48
+ print(f"\n🔍 Running inference on {len(test_dataset)} test samples...")
49
+ print("=" * 60)
50
+
51
+ # Iterate over evaluation set
52
+ for i, batch in enumerate(test_loader):
53
+ # Generate predictions
54
+ predictions = model.generate(batch, max_new_tokens=200)
55
+
56
+ # Print results
57
+ for sample, pred in zip(batch, predictions):
58
+ print(f"\n📝 Sample {i + 1}:")
59
+ if 'id' in sample:
60
+ print(f" Time Series ID: {sample['id']}")
61
+ if 'pre_prompt' in sample:
62
+ print(f" Prompt: {sample['pre_prompt']}")
63
+ print(f" Gold Caption: {sample.get('answer', 'N/A')}")
64
+ print(f" Model Output: {pred}")
65
+ print("-" * 60)
66
+
67
+ # Limit to first 5 samples for demo
68
+ if i >= 9:
69
+ print("\n✅ Demo complete! (Showing first 10 samples)")
70
+ break
71
+
72
+ if __name__ == "__main__":
73
+ main()
74
+
code/OpenTSLM/demo/huggingface/03_test_hf_har_cot.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+ """
6
+ Demo script for testing HAR CoT (Human Activity Recognition Chain-of-Thought) model from HuggingFace.
7
+
8
+ This script:
9
+ 1. Loads a pretrained model from HuggingFace Hub
10
+ 2. Loads the HAR CoT test dataset
11
+ 3. Generates predictions on the evaluation set
12
+ 4. Prints model outputs
13
+ """
14
+
15
+ from opentslm.model.llm.OpenTSLM import OpenTSLM
16
+ from opentslm.time_series_datasets.har_cot.HARCoTQADataset import HARCoTQADataset
17
+ from opentslm.time_series_datasets.util import extend_time_series_to_match_patch_size_and_aggregate
18
+ from torch.utils.data import DataLoader
19
+ from opentslm.model_config import PATCH_SIZE
20
+ import torch
21
+
22
+ # Model repository ID - change this to test different models
23
+ REPO_ID = "OpenTSLM/llama-3.2-1b-har-sp"
24
+
25
+ def main():
26
+ print("=" * 60)
27
+ print("HAR CoT Model Demo")
28
+ print("=" * 60)
29
+
30
+ # Load model from HuggingFace
31
+ print(f"\n📥 Loading model from {REPO_ID}...")
32
+ enable_lora = False
33
+ if "-sp" in REPO_ID:
34
+ enable_lora = True
35
+ model = OpenTSLM.load_pretrained(REPO_ID, enable_lora=enable_lora, device="cuda" if torch.cuda.is_available() else "cpu")
36
+
37
+
38
+
39
+ # Create dataset
40
+ print("\n📊 Loading HAR CoT test dataset...")
41
+ test_dataset = HARCoTQADataset("test", EOS_TOKEN=model.get_eos_token())
42
+
43
+ # Create data loader
44
+ test_loader = DataLoader(
45
+ test_dataset,
46
+ shuffle=False,
47
+ batch_size=1,
48
+ collate_fn=lambda batch: extend_time_series_to_match_patch_size_and_aggregate(
49
+ batch, patch_size=PATCH_SIZE
50
+ ),
51
+ )
52
+
53
+ print(f"\n🔍 Running inference on {len(test_dataset)} test samples...")
54
+ print("=" * 60)
55
+
56
+ # Iterate over evaluation set
57
+ for i, batch in enumerate(test_loader):
58
+ # Generate predictions
59
+ predictions = model.generate(batch, max_new_tokens=500)
60
+
61
+ # Print results
62
+ for sample, pred in zip(batch, predictions):
63
+ print(f"\n📝 Sample {i + 1}:")
64
+ if 'pre_prompt' in sample:
65
+ print(f" Question: {sample['pre_prompt']}")
66
+ print(f" Gold Answer: {sample.get('answer', 'N/A')}")
67
+ print(f" Model Output: {pred}")
68
+ print("-" * 60)
69
+
70
+ # Limit to first 5 samples for demo
71
+ if i >= 4:
72
+ print("\n✅ Demo complete! (Showing first 5 samples)")
73
+ break
74
+
75
+ if __name__ == "__main__":
76
+ main()
77
+
code/OpenTSLM/demo/huggingface/04_test_hf_sleep_cot.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+ """
6
+ Demo script for testing Sleep CoT (Sleep Stage Classification Chain-of-Thought) model from HuggingFace.
7
+
8
+ This script:
9
+ 1. Loads a pretrained model from HuggingFace Hub
10
+ 2. Loads the Sleep CoT test dataset
11
+ 3. Generates predictions on the evaluation set
12
+ 4. Prints model outputs
13
+ """
14
+
15
+ from opentslm.model.llm.OpenTSLM import OpenTSLM
16
+ from opentslm.time_series_datasets.sleep.SleepEDFCoTQADataset import SleepEDFCoTQADataset
17
+ from opentslm.time_series_datasets.util import extend_time_series_to_match_patch_size_and_aggregate
18
+ from torch.utils.data import DataLoader
19
+ from opentslm.model_config import PATCH_SIZE
20
+ import torch
21
+
22
+ # Model repository ID - change this to test different models
23
+ REPO_ID = "OpenTSLM/llama-3.2-1b-sleep-sp"
24
+
25
+ def main():
26
+ print("=" * 60)
27
+ print("Sleep CoT Model Demo")
28
+ print("=" * 60)
29
+
30
+ # Load model from HuggingFace
31
+ print(f"\n📥 Loading model from {REPO_ID}...")
32
+ enable_lora = False
33
+ if "-sp" in REPO_ID:
34
+ enable_lora = True
35
+ model = OpenTSLM.load_pretrained(REPO_ID, enable_lora=enable_lora, device="cuda" if torch.cuda.is_available() else "cpu")
36
+
37
+ # Create dataset
38
+ print("\n📊 Loading Sleep CoT test dataset...")
39
+ test_dataset = SleepEDFCoTQADataset("test", EOS_TOKEN=model.get_eos_token())
40
+
41
+ # Create data loader
42
+ test_loader = DataLoader(
43
+ test_dataset,
44
+ shuffle=False,
45
+ batch_size=1,
46
+ collate_fn=lambda batch: extend_time_series_to_match_patch_size_and_aggregate(
47
+ batch, patch_size=PATCH_SIZE
48
+ ),
49
+ )
50
+
51
+ print(f"\n🔍 Running inference on {len(test_dataset)} test samples...")
52
+ print("=" * 60)
53
+
54
+ # Iterate over evaluation set
55
+ for i, batch in enumerate(test_loader):
56
+ # Generate predictions
57
+ predictions = model.generate(batch, max_new_tokens=500)
58
+
59
+ # Print results
60
+ for sample, pred in zip(batch, predictions):
61
+ print(f"\n📝 Sample {i + 1}:")
62
+ if 'pre_prompt' in sample:
63
+ print(f" Question: {sample['pre_prompt']}")
64
+ print(f" Gold Answer: {sample.get('answer', 'N/A')}")
65
+ print(f" Model Output: {pred}")
66
+ print("-" * 60)
67
+
68
+ # Limit to first 5 samples for demo
69
+ if i >= 4:
70
+ print("\n✅ Demo complete! (Showing first 5 samples)")
71
+ break
72
+
73
+ if __name__ == "__main__":
74
+ main()
75
+
code/OpenTSLM/demo/huggingface/05_test_hf_ecg_qa_cot.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+ """
6
+ Demo script for testing ECG QA CoT (ECG Question Answering Chain-of-Thought) model from HuggingFace.
7
+
8
+ This script:
9
+ 1. Loads a pretrained model from HuggingFace Hub
10
+ 2. Loads the ECG QA CoT test dataset
11
+ 3. Generates predictions on the evaluation set
12
+ 4. Prints model outputs
13
+ """
14
+
15
+ from opentslm.model.llm.OpenTSLM import OpenTSLM
16
+ from opentslm.time_series_datasets.ecg_qa.ECGQACoTQADataset import ECGQACoTQADataset
17
+ from opentslm.time_series_datasets.util import extend_time_series_to_match_patch_size_and_aggregate
18
+ from torch.utils.data import DataLoader
19
+ from opentslm.model_config import PATCH_SIZE
20
+ import torch
21
+
22
+ # Model repository ID - change this to test different models
23
+ REPO_ID = "OpenTSLM/llama-3.2-1b-ecg-sp"
24
+
25
+ def main():
26
+ print("=" * 60)
27
+ print("ECG QA CoT Model Demo")
28
+ print("=" * 60)
29
+
30
+ # Load model from HuggingFace
31
+ print(f"\n📥 Loading model from {REPO_ID}...")
32
+ enable_lora = False
33
+ if "-sp" in REPO_ID:
34
+ enable_lora = True
35
+ model = OpenTSLM.load_pretrained(REPO_ID, enable_lora=enable_lora, device="cuda" if torch.cuda.is_available() else "cpu")
36
+
37
+ # Create dataset
38
+ print("\n📊 Loading ECG QA CoT test dataset...")
39
+ test_dataset = ECGQACoTQADataset("test", EOS_TOKEN=model.get_eos_token())
40
+
41
+ # Create data loader
42
+ test_loader = DataLoader(
43
+ test_dataset,
44
+ shuffle=False,
45
+ batch_size=1,
46
+ collate_fn=lambda batch: extend_time_series_to_match_patch_size_and_aggregate(
47
+ batch, patch_size=PATCH_SIZE
48
+ ),
49
+ )
50
+
51
+ print(f"\n🔍 Running inference on {len(test_dataset)} test samples...")
52
+ print("=" * 60)
53
+
54
+ # Iterate over evaluation set
55
+ for i, batch in enumerate(test_loader):
56
+ # Generate predictions
57
+ predictions = model.generate(batch, max_new_tokens=500)
58
+
59
+ # Print results
60
+ for sample, pred in zip(batch, predictions):
61
+ print(f"\n📝 Sample {i + 1}:")
62
+ if 'pre_prompt' in sample:
63
+ print(f" Question: {sample['pre_prompt']}")
64
+ if 'template_id' in sample:
65
+ print(f" Template ID: {sample['template_id']}")
66
+ if 'ecg_id' in sample:
67
+ print(f" ECG ID: {sample['ecg_id']}")
68
+ print(f" Gold Answer: {sample.get('answer', 'N/A')}")
69
+ print(f" Model Output: {pred}")
70
+ print("-" * 60)
71
+
72
+ # Limit to first 5 samples for demo
73
+ if i >= 4:
74
+ print("\n✅ Demo complete! (Showing first 5 samples)")
75
+ break
76
+
77
+ if __name__ == "__main__":
78
+ main()
79
+
code/OpenTSLM/evaluation/baseline/README.md ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
3
+ SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
4
+
5
+ SPDX-License-Identifier: MIT
6
+ -->
7
+
8
+ # Time Series LLM Evaluation Framework
9
+
10
+ This directory contains a modular evaluation framework for testing LLMs on time series datasets. The system is designed to be easily extensible to new datasets and models.
11
+
12
+ ## Overview
13
+
14
+ The evaluation framework consists of:
15
+
16
+ 1. **`common_evaluator.py`** - Core evaluation logic that can be reused across datasets
17
+ 2. **`evaluate_tsqa.py`** - TSQA-specific evaluation
18
+ 3. **`evaluate_pamap.py`** - PAMAP-specific evaluation
19
+ 4. **`evaluate_all.py`** - Combined evaluation across all datasets
20
+ 5. **`test_baseline.py`** - Original baseline test (legacy)
21
+
22
+ ## Quick Start
23
+
24
+ ### Running Individual Dataset Evaluations
25
+
26
+ ```bash
27
+ # Evaluate on TSQA dataset
28
+ python evaluate_tsqa.py
29
+
30
+ # Evaluate on PAMAP datasets
31
+ python evaluate_pamap.py
32
+
33
+ # Evaluate on all datasets
34
+ python evaluate_all.py
35
+ ```
36
+
37
+ ### Adding New Models
38
+
39
+ To add new models, simply update the `model_names` list in any evaluation script:
40
+
41
+ ```python
42
+ model_names = [
43
+ "meta-llama/Llama-3.2-1B",
44
+ "google/gemma-3n-e2b",
45
+ "google/gemma-3n-e2b-it",
46
+ "microsoft/DialoGPT-medium",
47
+ "gpt2",
48
+ ]
49
+ ```
50
+
51
+ ### Adding New Datasets
52
+
53
+ To add a new dataset:
54
+
55
+ 1. Create a new evaluation file (e.g., `evaluate_newdataset.py`)
56
+ 2. Define an evaluation function that takes `(ground_truth, prediction)` and returns metrics
57
+ 3. Import your dataset class and evaluation function in `evaluate_all.py`
58
+
59
+ Example evaluation function:
60
+
61
+ ```python
62
+ def evaluate_newdataset(ground_truth: str, prediction: str) -> Dict[str, Any]:
63
+ """Evaluate predictions for new dataset."""
64
+ gt_clean = ground_truth.lower().strip()
65
+ pred_clean = prediction.lower().strip()
66
+
67
+ exact_match = gt_clean == pred_clean
68
+ similarity = SequenceMatcher(None, gt_clean, pred_clean).ratio()
69
+
70
+ return {
71
+ "exact_match": int(exact_match),
72
+ "similarity": similarity,
73
+ "ground_truth": gt_clean,
74
+ "prediction": pred_clean,
75
+ }
76
+ ```
77
+
78
+ ## Output Files
79
+
80
+ The evaluation system generates several output files:
81
+
82
+ 1. **Individual Results**: `evaluation_results_{model}_{dataset}.json` - Detailed results for each model-dataset combination
83
+ 2. **Summary CSV**: `evaluation_results_{timestamp}.csv` - Pandas DataFrame with all results
84
+ 3. **Console Output**: Real-time progress and summary statistics
85
+
86
+ ## Metrics
87
+
88
+ The framework calculates several metrics for each prediction:
89
+
90
+ - **exact_match**: Binary indicator for exact string match
91
+ - **partial_match**: Binary indicator for partial string match
92
+ - **contains_answer**: Binary indicator if prediction contains ground truth
93
+ - **similarity**: Character-level similarity score (0-1)
94
+ - **has_reasoning**: For CoT datasets, indicates if prediction contains reasoning
95
+
96
+ ## Configuration
97
+
98
+ ### Sample Limits
99
+
100
+ For faster testing, you can limit the number of samples:
101
+
102
+ ```python
103
+ max_samples=50 # Process only first 50 samples
104
+ max_samples=None # Process all samples
105
+ ```
106
+
107
+ ### Model Parameters
108
+
109
+ You can customize model parameters:
110
+
111
+ ```python
112
+ results_df = evaluator.evaluate_multiple_models(
113
+ model_names=model_names,
114
+ dataset_classes=dataset_classes,
115
+ evaluation_functions=evaluation_functions,
116
+ max_samples=50,
117
+ temperature=0.1, # Model temperature
118
+ max_new_tokens=100, # Maximum tokens to generate
119
+ )
120
+ ```
121
+
122
+ ## Dataset-Specific Considerations
123
+
124
+ ### TSQADataset
125
+ - Extracts answers after "Answer:" in predictions
126
+ - Uses built-in time series formatting from dataset class
127
+ - Calculates similarity metrics
128
+
129
+ ### PAMAP2AccQADataset
130
+ - Activity classification from accelerometer data
131
+ - Standard exact/partial match metrics
132
+
133
+ ### PAMAP2CoTQADataset
134
+ - Chain-of-thought reasoning evaluation
135
+ - Additional "has_reasoning" metric
136
+ - More complex answer extraction
137
+
138
+ ## Extending the Framework
139
+
140
+ ### Adding Custom Metrics
141
+
142
+ To add custom metrics, modify the evaluation function:
143
+
144
+ ```python
145
+ def evaluate_custom(ground_truth: str, prediction: str) -> Dict[str, Any]:
146
+ # ... existing metrics ...
147
+
148
+ # Add custom metric
149
+ custom_metric = calculate_custom_metric(ground_truth, prediction)
150
+
151
+ return {
152
+ # ... existing metrics ...
153
+ "custom_metric": custom_metric,
154
+ }
155
+ ```
156
+
157
+ ### Adding New Dataset Classes
158
+
159
+ 1. Ensure your dataset class inherits from `QADataset`
160
+ 2. Implement the required abstract methods
161
+ 3. Create an evaluation function
162
+ 4. Add to the evaluation scripts
163
+
164
+ ### Batch Processing
165
+
166
+ For large-scale evaluations, you can modify the framework to process models in batches or use distributed processing.
167
+
168
+ ## Troubleshooting
169
+
170
+ ### Common Issues
171
+
172
+ 1. **Model Loading Errors**: Check if the model name is correct and accessible
173
+ 2. **Memory Issues**: Reduce `max_samples` or use smaller models
174
+ 3. **Import Errors**: Ensure all dataset classes are properly imported
175
+
176
+ ### Debug Mode
177
+
178
+ To enable detailed debugging, modify the evaluation scripts to print more information:
179
+
180
+ ```python
181
+ # In common_evaluator.py, set debug=True
182
+ if idx < 10: # Print more samples for debugging
183
+ print(f"Sample {idx}: {input_text[:200]}...")
184
+ ```
185
+
186
+ ## Performance Tips
187
+
188
+ 1. **Use GPU**: The framework automatically detects and uses CUDA/MPS if available
189
+ 2. **Batch Processing**: For multiple models, consider running them in parallel
190
+ 3. **Sample Limiting**: Use `max_samples` for quick testing before full evaluation
191
+ 4. **Model Caching**: Models are loaded once per evaluation run
192
+
193
+ ## Using OpenAI Models (ChatGPT, GPT-4, etc.)
194
+
195
+ You can now evaluate OpenAI models (e.g., ChatGPT, GPT-4) using the same evaluation scripts. To do so:
196
+
197
+ 1. **Set your OpenAI API key** (required):
198
+
199
+ ```bash
200
+ export OPENAI_API_KEY=sk-...
201
+ ```
202
+
203
+ 2. **Run the evaluation script with an OpenAI model name prefixed by `openai-`**:
204
+
205
+ ```bash
206
+ python evaluate_tsqa.py openai-gpt-4
207
+ python evaluate_pamap.py openai-gpt-3.5-turbo
208
+ ```
209
+ This will use the OpenAI API instead of a local HuggingFace model.
210
+
211
+ 3. **Notes:**
212
+ - The model name after `openai-` should match the OpenAI API model name (e.g., `gpt-4`, `gpt-3.5-turbo`).
213
+ - You can adjust `max_new_tokens` and other parameters as usual.
code/OpenTSLM/evaluation/baseline/common_evaluator.py ADDED
@@ -0,0 +1,722 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ import json
7
+ import os
8
+ import io
9
+ import re
10
+ import sys
11
+ import base64
12
+ from typing import Type, Callable, Dict, List, Any, Optional
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ import torch
17
+ from torch.utils.data import Dataset
18
+ from tqdm import tqdm
19
+ from transformers.pipelines import pipeline
20
+ import matplotlib.pyplot as plt
21
+ from time import sleep
22
+
23
+ from opentslm.logger import get_logger
24
+
25
+ from .openai_pipeline import OpenAIPipeline
26
+
27
+
28
+ class CommonEvaluator:
29
+ """
30
+ A common evaluation framework for testing LLMs on time series datasets.
31
+ """
32
+
33
+ def __init__(self, device: Optional[str] = None):
34
+ """
35
+ Initialize the evaluator.
36
+
37
+ Args:
38
+ device: Device to use for inference ('cuda', 'mps', 'cpu', or None for auto)
39
+ """
40
+ self.device = device or self._get_best_device()
41
+
42
+ if self.device == "mps":
43
+ print(
44
+ "⚠️ Warning!! MPS is available but not recommended for evaluation. Many LLMs do not produce reasonable output!"
45
+ )
46
+ print(
47
+ "⚠️ Warning!! MPS is available but not recommended for evaluation. Many LLMs do not produce reasonable output!"
48
+ )
49
+ print("⚠️ Better use CPU or CUDA for evaluation.")
50
+ sleep(10)
51
+
52
+ def _get_best_device(self) -> str:
53
+ """Get the best available device."""
54
+ if torch.cuda.is_available():
55
+ return "cuda"
56
+ elif torch.backends.mps.is_available():
57
+ return "mps"
58
+ else:
59
+ return "cpu"
60
+
61
+ def load_model(self, model_name: str, **pipeline_kwargs) -> pipeline:
62
+ """
63
+ Load a model using transformers pipeline or OpenAI API.
64
+ """
65
+ self.current_model_name = (
66
+ model_name # Track the current model name for formatter selection
67
+ )
68
+ if model_name.startswith("openai-"):
69
+ # Use OpenAI API
70
+ openai_model = model_name.replace("openai-", "")
71
+ return OpenAIPipeline(model_name=openai_model, **pipeline_kwargs)
72
+ print(f"Loading model: {model_name}")
73
+ print(f"Using device: {self.device}")
74
+ # Default pipeline arguments
75
+ default_kwargs = {
76
+ "task": "text-generation",
77
+ "device": self.device,
78
+ "temperature": 0.1,
79
+ }
80
+ default_kwargs.update(pipeline_kwargs)
81
+ pipe = pipeline(model=model_name, **default_kwargs)
82
+ print(f"Model loaded successfully: {model_name}")
83
+ return pipe
84
+
85
+ def load_dataset(
86
+ self,
87
+ dataset_class: Type[Dataset],
88
+ split: str = "test",
89
+ format_sample_str: bool = True,
90
+ max_samples: Optional[int] = None,
91
+ **dataset_kwargs,
92
+ ) -> Dataset:
93
+ """
94
+ Load a dataset with proper formatting.
95
+ """
96
+ print(f"Loading dataset: {dataset_class.__name__}")
97
+
98
+ # Import the gruver formatters
99
+ from .gruver_llmtime_tokenizer import gpt_formatter, llama_formatter
100
+
101
+ # Choose formatter based on model type
102
+ model_name = getattr(self, "current_model_name", None)
103
+ if model_name is None and "model_name" in dataset_kwargs:
104
+ model_name = dataset_kwargs["model_name"]
105
+ if model_name is not None:
106
+ if model_name.startswith("openai-") or "gpt" in model_name.lower():
107
+ formatter = gpt_formatter
108
+ print(f"Using GPT formatter for model: {model_name}")
109
+ elif "llama" in model_name.lower():
110
+ formatter = llama_formatter
111
+ print(f"Using Llama formatter for model: {model_name}")
112
+ else:
113
+ print(f"Defaulting to Llama formatter for model: {model_name}")
114
+
115
+ formatter = llama_formatter
116
+ else:
117
+ formatter = llama_formatter
118
+
119
+ # Default dataset arguments
120
+ default_kwargs = {
121
+ "split": split,
122
+ "EOS_TOKEN": "",
123
+ "format_sample_str": format_sample_str,
124
+ "time_series_format_function": formatter,
125
+ }
126
+
127
+ # Add max_samples if provided
128
+ if max_samples is not None:
129
+ default_kwargs["max_samples"] = max_samples
130
+
131
+ # Update with provided kwargs
132
+ default_kwargs.update(dataset_kwargs)
133
+
134
+ dataset = dataset_class(**default_kwargs)
135
+ print(f"Loaded {len(dataset)} {split} samples")
136
+
137
+ return dataset
138
+
139
+ def evaluate_model_on_dataset(
140
+ self,
141
+ model_name: str,
142
+ dataset_class: Type[Dataset],
143
+ evaluation_function: Callable[[str, str], Dict[str, Any]],
144
+ max_samples: Optional[int] = None,
145
+ use_plot: bool = False,
146
+ **pipeline_kwargs,
147
+ ) -> Dict[str, Any]:
148
+ """
149
+ Evaluate a model on a dataset using a custom evaluation function.
150
+
151
+ Args:
152
+ model_name: Name of the model to evaluate
153
+ dataset_class: Dataset class to use
154
+ evaluation_function: Function that takes (ground_truth, prediction) and returns metrics
155
+ max_samples: Maximum number of samples to evaluate (None for all)
156
+ **pipeline_kwargs: Additional arguments for model pipeline
157
+
158
+ Returns:
159
+ Dictionary containing evaluation results
160
+ """
161
+ print(
162
+ f"Starting evaluation with model {model_name} on dataset {dataset_class.__name__}"
163
+ )
164
+ print("=" * 60)
165
+
166
+ # Load model
167
+ pipe = self.load_model(model_name, **pipeline_kwargs)
168
+
169
+ # Load dataset
170
+ dataset = self.load_dataset(dataset_class, max_samples=max_samples)
171
+
172
+ # Check for existing results to resume from
173
+ existing_count = self._get_existing_results_count(
174
+ model_name, dataset_class.__name__
175
+ )
176
+ start_idx = existing_count
177
+
178
+ # Limit samples if specified
179
+ if max_samples is not None:
180
+ dataset_size = min(len(dataset), max_samples)
181
+ print(f"Processing samples {start_idx} to {dataset_size}...")
182
+ else:
183
+ dataset_size = len(dataset)
184
+ print(f"Processing samples {start_idx} to {dataset_size}...")
185
+
186
+ if start_idx >= dataset_size:
187
+ print(f"✅ All {dataset_size} samples already processed!")
188
+ return self._consolidate_jsonl_results(model_name, dataset_class.__name__)
189
+
190
+ # Initialize tracking
191
+ total_samples = dataset_size
192
+ successful_inferences = existing_count # Start with existing count
193
+ all_metrics = []
194
+ results = []
195
+ first_error_printed = False # Track if we've printed the first error
196
+
197
+ print("\nRunning inference...")
198
+ print("=" * 80)
199
+
200
+ # Get max_new_tokens for generation (default 1000)
201
+ max_new_tokens = pipeline_kwargs.pop("max_new_tokens", 1000)
202
+
203
+ # Load existing metrics if resuming
204
+ if start_idx > 0:
205
+ print(f"📂 Loading existing results from {start_idx} completed samples...")
206
+ jsonl_file = self._get_jsonl_file_path(model_name, dataset_class.__name__)
207
+ if os.path.exists(jsonl_file):
208
+ with open(jsonl_file, "r") as f:
209
+ for line in f:
210
+ if line.strip():
211
+ result = json.loads(line.strip())
212
+ all_metrics.append(result["metrics"])
213
+ results.append(result)
214
+
215
+ # Process each sample starting from where we left off
216
+ for idx in tqdm(range(start_idx, dataset_size), desc="Processing samples"):
217
+ try:
218
+ sample = dataset[idx]
219
+ plot_data = None
220
+
221
+ # Clean up prompt for TSQADataset (if needed)
222
+ if use_plot and hasattr(sample, "get") and sample.get("prompt"):
223
+ plot_data = self.get_plot_from_prompt(sample["prompt"])
224
+ pattern = r"The following is the accelerometer data on the [xyz]-axis\n([\-0-9, ]+)"
225
+ sample["prompt"] = re.sub(pattern, "", sample["prompt"])
226
+
227
+ # Clean up prompt for TSQADataset (if needed)
228
+ if hasattr(sample, "get") and sample.get("prompt"):
229
+ pattern = r"This is the time series, it has mean (-?\d+\.\d{4}) and std (-?\d+\.\d{4})\."
230
+ replacement = "This is the time series:"
231
+ sample["prompt"] = re.sub(pattern, replacement, sample["prompt"])
232
+
233
+ # Create input text
234
+ input_text = sample["prompt"]
235
+ target_answer = sample["answer"]
236
+
237
+ # Generate prediction
238
+ outputs = pipe(
239
+ input_text,
240
+ max_new_tokens=max_new_tokens,
241
+ return_full_text=False,
242
+ plot_data=plot_data,
243
+ )
244
+
245
+ # Extract generated text
246
+ if outputs and len(outputs) > 0:
247
+ generated_text = outputs[0]["generated_text"].strip()
248
+ successful_inferences += 1
249
+
250
+ # Evaluate using custom function (optionally with sample)
251
+ try:
252
+ import inspect
253
+
254
+ sig = inspect.signature(evaluation_function)
255
+ if len(sig.parameters) >= 3:
256
+ metrics = evaluation_function(
257
+ target_answer, generated_text, sample
258
+ )
259
+ else:
260
+ metrics = evaluation_function(target_answer, generated_text)
261
+ except Exception:
262
+ # Fallback to 2-arg call
263
+ metrics = evaluation_function(target_answer, generated_text)
264
+ all_metrics.append(metrics)
265
+
266
+ # Store detailed results
267
+ result = {
268
+ "sample_idx": idx,
269
+ "input_text": input_text,
270
+ "target_answer": target_answer,
271
+ "generated_answer": generated_text,
272
+ "metrics": metrics,
273
+ }
274
+ # Include template_id if present in sample for downstream analysis
275
+ if isinstance(sample, dict) and "template_id" in sample:
276
+ result["template_id"] = sample["template_id"]
277
+ results.append(result)
278
+
279
+ # Save individual result immediately to prevent data loss
280
+ self._save_individual_result(
281
+ result, model_name, dataset_class.__name__
282
+ )
283
+
284
+ # Print progress for first few samples
285
+ if idx < 10:
286
+ print(f"\nSAMPLE {idx + 1}:")
287
+ print(f"PROMPT: {input_text}...")
288
+ print(f"TARGET: {target_answer}")
289
+ print(f"PREDICTION: {generated_text}")
290
+ print(f"METRICS: {metrics}")
291
+ print("=" * 80)
292
+
293
+ # Print first error for debugging
294
+ if not first_error_printed and metrics.get("accuracy", 1) == 0:
295
+ print(f"\n❌ FIRST ERROR (Sample {idx + 1}):")
296
+ print(f"TARGET: {target_answer}")
297
+ print(f"PREDICTION: {generated_text}")
298
+ print("=" * 80)
299
+ first_error_printed = True
300
+ except Exception as e:
301
+ print(f"Error processing sample {idx}: {e}")
302
+ continue
303
+
304
+ # Calculate aggregate metrics
305
+ if successful_inferences > 0:
306
+ # Aggregate metrics across all samples
307
+ aggregate_metrics = self._aggregate_metrics(all_metrics)
308
+
309
+ # Calculate success rate
310
+ success_rate = successful_inferences / total_samples
311
+
312
+ # Prepare final results
313
+ final_results = {
314
+ "model_name": model_name,
315
+ "dataset_name": dataset_class.__name__,
316
+ "total_samples": total_samples,
317
+ "successful_inferences": successful_inferences,
318
+ "success_rate": success_rate,
319
+ "metrics": aggregate_metrics,
320
+ "detailed_results": results,
321
+ }
322
+
323
+ # Print summary
324
+ self._print_summary(final_results)
325
+
326
+ # Consolidate JSONL results into final JSON file
327
+ consolidated_file = self._consolidate_jsonl_results(
328
+ model_name, dataset_class.__name__
329
+ )
330
+ if consolidated_file:
331
+ # Update the consolidated file with correct total_samples
332
+ with open(consolidated_file, "r") as f:
333
+ consolidated_data = json.load(f)
334
+ consolidated_data["total_samples"] = total_samples
335
+ consolidated_data["success_rate"] = success_rate
336
+ with open(consolidated_file, "w") as f:
337
+ json.dump(consolidated_data, f, indent=2)
338
+
339
+ return final_results
340
+ else:
341
+ print("❌ No successful inferences completed!")
342
+ return {
343
+ "model_name": model_name,
344
+ "dataset_name": dataset_class.__name__,
345
+ "total_samples": total_samples,
346
+ "successful_inferences": 0,
347
+ "success_rate": 0.0,
348
+ "metrics": {},
349
+ "detailed_results": [],
350
+ }
351
+
352
+ def _aggregate_metrics(self, metrics_list: List[Dict[str, Any]]) -> Dict[str, Any]:
353
+ """
354
+ Aggregate metrics across all samples.
355
+
356
+ Args:
357
+ metrics_list: List of metric dictionaries
358
+
359
+ Returns:
360
+ Aggregated metrics
361
+ """
362
+ if not metrics_list:
363
+ return {}
364
+
365
+ # Get all unique metric keys
366
+ all_keys = set()
367
+ for metrics in metrics_list:
368
+ all_keys.update(metrics.keys())
369
+
370
+ aggregated = {}
371
+ for key in all_keys:
372
+ values = [metrics.get(key, 0) for metrics in metrics_list]
373
+ if all(isinstance(v, (int, float)) for v in values):
374
+ # Calculate overall accuracy/percentage
375
+ accuracy = np.mean(values) * 100
376
+ aggregated[key] = accuracy
377
+ else:
378
+ # For non-numeric metrics, just count occurrences
379
+ aggregated[key] = {
380
+ "values": values,
381
+ "count": len(values),
382
+ }
383
+
384
+ return aggregated
385
+
386
+ def _print_summary(self, results: Dict[str, Any]):
387
+ """Print evaluation summary."""
388
+ print("\n" + "=" * 80)
389
+ print("EVALUATION RESULTS")
390
+ print("=" * 80)
391
+ print(f"Model: {results['model_name']}")
392
+ print(f"Dataset: {results['dataset_name']}")
393
+ print(f"Total samples processed: {results['total_samples']}")
394
+ print(f"Successful inferences: {results['successful_inferences']}")
395
+ print(f"Success rate: {results['success_rate']:.2%}")
396
+
397
+ if results["metrics"]:
398
+ print("\nAggregated Metrics:")
399
+ for metric_name, metric_values in results["metrics"].items():
400
+ if isinstance(metric_values, (int, float)):
401
+ print(f" {metric_name}: {metric_values:.1f}%")
402
+ else:
403
+ print(f" {metric_name}: {metric_values}")
404
+
405
+ def _save_results(self, results: Dict[str, Any]):
406
+ """Save detailed results to file."""
407
+ import os
408
+
409
+ current_dir = os.path.dirname(os.path.abspath(__file__))
410
+ detailed_dir = os.path.join(
411
+ current_dir, "..", "results", "baseline", "detailed"
412
+ )
413
+ os.makedirs(detailed_dir, exist_ok=True)
414
+ normalized_model_id = re.sub(r"[^a-z0-9]", "-", results["model_name"].lower())
415
+ normalized_dataset_name = re.sub(
416
+ r"[^a-z0-9]", "-", results["dataset_name"].lower()
417
+ )
418
+ results_file = os.path.join(
419
+ detailed_dir,
420
+ f"evaluation_results_{normalized_model_id}_{normalized_dataset_name}.json",
421
+ )
422
+
423
+ with open(results_file, "w") as f:
424
+ json.dump(results, f, indent=2)
425
+
426
+ print(f"\nDetailed results saved to: {results_file}")
427
+
428
+ def _save_individual_result(
429
+ self, result: Dict[str, Any], model_name: str, dataset_name: str
430
+ ):
431
+ """Save individual result incrementally to prevent data loss."""
432
+ import os
433
+
434
+ current_dir = os.path.dirname(os.path.abspath(__file__))
435
+ detailed_dir = os.path.join(
436
+ current_dir, "..", "results", "baseline", "detailed"
437
+ )
438
+ os.makedirs(detailed_dir, exist_ok=True)
439
+ normalized_model_id = re.sub(r"[^a-z0-9]", "-", model_name.lower())
440
+ normalized_dataset_name = re.sub(r"[^a-z0-9]", "-", dataset_name.lower())
441
+ results_file = os.path.join(
442
+ detailed_dir,
443
+ f"evaluation_results_{normalized_model_id}_{normalized_dataset_name}.jsonl",
444
+ )
445
+
446
+ # Append individual result as JSONL
447
+ with open(results_file, "a") as f:
448
+ json.dump(result, f)
449
+ f.write("\n")
450
+
451
+ def _consolidate_jsonl_results(self, model_name: str, dataset_name: str) -> str:
452
+ """Consolidate JSONL results into final JSON file."""
453
+ import os
454
+
455
+ current_dir = os.path.dirname(os.path.abspath(__file__))
456
+ detailed_dir = os.path.join(
457
+ current_dir, "..", "results", "baseline", "detailed"
458
+ )
459
+ normalized_model_id = re.sub(r"[^a-z0-9]", "-", model_name.lower())
460
+ normalized_dataset_name = re.sub(r"[^a-z0-9]", "-", dataset_name.lower())
461
+
462
+ jsonl_file = os.path.join(
463
+ detailed_dir,
464
+ f"evaluation_results_{normalized_model_id}_{normalized_dataset_name}.jsonl",
465
+ )
466
+ json_file = os.path.join(
467
+ detailed_dir,
468
+ f"evaluation_results_{normalized_model_id}_{normalized_dataset_name}.json",
469
+ )
470
+
471
+ # Read all JSONL results
472
+ individual_results = []
473
+ if os.path.exists(jsonl_file):
474
+ with open(jsonl_file, "r") as f:
475
+ for line in f:
476
+ if line.strip():
477
+ individual_results.append(json.loads(line.strip()))
478
+
479
+ # Create consolidated results structure
480
+ if individual_results:
481
+ # Calculate aggregate metrics
482
+ all_metrics = [result["metrics"] for result in individual_results]
483
+ aggregate_metrics = self._aggregate_metrics(all_metrics)
484
+
485
+ # Calculate success rate
486
+ successful_inferences = len(individual_results)
487
+ total_samples = len(individual_results) # This will be updated by caller
488
+
489
+ consolidated_results = {
490
+ "model_name": model_name,
491
+ "dataset_name": dataset_name,
492
+ "total_samples": total_samples,
493
+ "successful_inferences": successful_inferences,
494
+ "success_rate": (
495
+ successful_inferences / total_samples if total_samples > 0 else 0.0
496
+ ),
497
+ "metrics": aggregate_metrics,
498
+ "detailed_results": individual_results,
499
+ }
500
+
501
+ # Save consolidated results
502
+ with open(json_file, "w") as f:
503
+ json.dump(consolidated_results, f, indent=2)
504
+
505
+ print(f"\nConsolidated results saved to: {json_file}")
506
+ return json_file
507
+
508
+ return None
509
+
510
+ def _get_existing_results_count(self, model_name: str, dataset_name: str) -> int:
511
+ """Get count of existing results from JSONL file for resuming interrupted evaluations."""
512
+ import os
513
+
514
+ current_dir = os.path.dirname(os.path.abspath(__file__))
515
+ detailed_dir = os.path.join(
516
+ current_dir, "..", "results", "baseline", "detailed"
517
+ )
518
+ normalized_model_id = re.sub(r"[^a-z0-9]", "-", model_name.lower())
519
+ normalized_dataset_name = re.sub(r"[^a-z0-9]", "-", dataset_name.lower())
520
+ jsonl_file = os.path.join(
521
+ detailed_dir,
522
+ f"evaluation_results_{normalized_model_id}_{normalized_dataset_name}.jsonl",
523
+ )
524
+
525
+ if os.path.exists(jsonl_file):
526
+ if line.strip():
527
+ count += 1
528
+ return count
529
+ return 0
530
+
531
+ def _get_jsonl_file_path(self, model_name: str, dataset_name: str) -> str:
532
+ """Get the JSONL file path for a model-dataset combination."""
533
+ import os
534
+
535
+ current_dir = os.path.dirname(os.path.abspath(__file__))
536
+ detailed_dir = os.path.join(
537
+ current_dir, "..", "results", "baseline", "detailed"
538
+ )
539
+ normalized_model_id = re.sub(r"[^a-z0-9]", "-", model_name.lower())
540
+ normalized_dataset_name = re.sub(r"[^a-z0-9]", "-", dataset_name.lower())
541
+ return os.path.join(
542
+ detailed_dir,
543
+ f"evaluation_results_{normalized_model_id}_{normalized_dataset_name}.jsonl",
544
+ )
545
+
546
+ def evaluate_multiple_models(
547
+ self,
548
+ model_names: List[str],
549
+ dataset_classes: List[Type[Dataset]],
550
+ evaluation_functions: Dict[str, Callable[[str, str], Dict[str, Any]]],
551
+ max_samples: Optional[int] = None,
552
+ **pipeline_kwargs,
553
+ ) -> pd.DataFrame:
554
+ """
555
+ Evaluate multiple models on multiple datasets.
556
+
557
+ Args:
558
+ model_names: List of model names to evaluate
559
+ dataset_classes: List of dataset classes to evaluate on
560
+ evaluation_functions: Dictionary mapping dataset class names to evaluation functions
561
+ max_samples: Maximum number of samples per evaluation
562
+ **pipeline_kwargs: Additional arguments for model pipeline
563
+
564
+ Returns:
565
+ DataFrame with results for all model-dataset combinations
566
+ """
567
+ all_results = []
568
+
569
+ # Generate filename once at the beginning
570
+ import os
571
+
572
+ current_dir = os.path.dirname(os.path.abspath(__file__))
573
+ results_dir = os.path.join(current_dir, "..", "results", "baseline")
574
+ os.makedirs(results_dir, exist_ok=True)
575
+ df_filename = os.path.join(results_dir, "evaluation_results.csv")
576
+ print(f"Results will be saved to: {df_filename}")
577
+
578
+ # Load existing results if file exists
579
+ existing_df = None
580
+ if os.path.exists(df_filename):
581
+ try:
582
+ existing_df = pd.read_csv(df_filename)
583
+ print(f"Found existing results file with {len(existing_df)} entries")
584
+ except Exception as e:
585
+ print(f"Warning: Could not read existing results file: {e}")
586
+
587
+ for model_name in model_names:
588
+ for dataset_class in dataset_classes:
589
+ dataset_name = dataset_class.__name__
590
+
591
+ if dataset_name not in evaluation_functions:
592
+ print(f"Warning: No evaluation function found for {dataset_name}")
593
+ continue
594
+
595
+ # Check if this model-dataset combination already exists in results
596
+ if existing_df is not None:
597
+ existing_result = existing_df[
598
+ (existing_df["model"] == model_name)
599
+ & (existing_df["dataset"] == dataset_name)
600
+ ]
601
+ if not existing_result.empty:
602
+ print(
603
+ f"⏭️ Skipping {model_name} on {dataset_name} (already evaluated)"
604
+ )
605
+ continue
606
+
607
+ evaluation_function = evaluation_functions[dataset_name]
608
+
609
+ print(f"\n{'=' * 80}")
610
+ print(f"Evaluating {model_name} on {dataset_name}")
611
+ print(f"{'=' * 80}")
612
+
613
+ try:
614
+ results = self.evaluate_model_on_dataset(
615
+ model_name=model_name,
616
+ dataset_class=dataset_class,
617
+ evaluation_function=evaluation_function,
618
+ max_samples=max_samples,
619
+ use_plot=False,
620
+ **pipeline_kwargs,
621
+ )
622
+
623
+ # Extract key metrics for DataFrame
624
+ row = {
625
+ "model": model_name,
626
+ "dataset": dataset_name,
627
+ "total_samples": results["total_samples"],
628
+ "successful_inferences": results["successful_inferences"],
629
+ "success_rate": results["success_rate"],
630
+ }
631
+
632
+ # Add specific metrics
633
+ if results["metrics"]:
634
+ for metric_name, metric_values in results["metrics"].items():
635
+ if isinstance(metric_values, (int, float)):
636
+ row[metric_name] = metric_values
637
+ else:
638
+ row[metric_name] = str(metric_values)
639
+
640
+ all_results.append(row)
641
+
642
+ # Combine with existing results and save
643
+ current_df = pd.DataFrame(all_results)
644
+ if existing_df is not None:
645
+ # Append new results
646
+ final_df = pd.concat(
647
+ [existing_df, current_df], ignore_index=True
648
+ )
649
+ else:
650
+ final_df = current_df
651
+
652
+ final_df.to_csv(df_filename, index=False)
653
+ print(f"✅ Results updated: {df_filename}")
654
+
655
+ except Exception as e:
656
+ print(f"Error evaluating {model_name} on {dataset_name}: {e}")
657
+ all_results.append(
658
+ {
659
+ "model": model_name,
660
+ "dataset": dataset_name,
661
+ "status": "Failed",
662
+ }
663
+ )
664
+
665
+ # Save DataFrame even after errors
666
+ current_df = pd.DataFrame(all_results)
667
+ if existing_df is not None:
668
+ final_df = pd.concat(
669
+ [existing_df, current_df], ignore_index=True
670
+ )
671
+ else:
672
+ final_df = current_df
673
+ final_df.to_csv(df_filename, index=False)
674
+ print(f"⚠️ Results updated (with error): {df_filename}")
675
+
676
+ print(f"\nFinal results saved to: {df_filename}")
677
+ return final_df
678
+
679
+ def get_plot_from_prompt(self, prompt: str):
680
+ """
681
+ Parse time series data from the prompt and return a base64 image.
682
+ """
683
+ # Parse the time series data from the prompt
684
+ time_series_data = []
685
+
686
+ # Extract data for each axis using regex
687
+ axes = ["x-axis", "y-axis", "z-axis"]
688
+ for axis in axes:
689
+ pattern = f"accelerometer data on the {axis}\\n([\\-0-9, ]+)"
690
+ match = re.search(pattern, prompt.lower())
691
+ if match:
692
+ # Extract the data and convert to a list of integers
693
+ data_str = match.group(1).strip()
694
+ data_str = data_str.replace(" ", "")
695
+ data = [int(val.strip()) for val in data_str.split(",") if val.strip()]
696
+ time_series_data.append(data)
697
+
698
+ # Create the plot
699
+ num_series = len(time_series_data)
700
+ fig, axes = plt.subplots(
701
+ num_series, 1, figsize=(10, 4 * num_series), sharex=True
702
+ )
703
+ # If there's only one series, axes won't be an array
704
+ if num_series == 1:
705
+ axes = [axes]
706
+
707
+ # Plot each time series in its own subplot
708
+ axis_names = {0: "X-axis", 1: "Y-axis", 2: "Z-axis"}
709
+ for i, series in enumerate(time_series_data):
710
+ axes[i].plot(series, marker="o", linestyle="-", markersize=0)
711
+ axes[i].grid(True, alpha=0.3)
712
+ axes[i].set_title(f"Accelerometer - {axis_names.get(i)}")
713
+
714
+ plt.tight_layout()
715
+
716
+ # Convert plot to base64 image
717
+ img_buffer = io.BytesIO()
718
+ plt.savefig(img_buffer, format="png", bbox_inches="tight", dpi=100)
719
+ plt.close()
720
+ img_buffer.seek(0)
721
+ image_data = base64.b64encode(img_buffer.getvalue()).decode("utf-8")
722
+ return image_data
code/OpenTSLM/evaluation/baseline/common_evaluator_plot.py ADDED
@@ -0,0 +1,459 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ import io
7
+ import base64
8
+ from typing import Type, Callable, Dict, List, Any, Optional
9
+
10
+ import torch
11
+ from torch.utils.data import Dataset
12
+ from tqdm import tqdm
13
+ from transformers.pipelines import pipeline
14
+ import matplotlib.pyplot as plt
15
+ from time import sleep
16
+ from PIL import Image
17
+ import pandas as pd
18
+
19
+
20
+ from openai_pipeline import OpenAIPipeline
21
+ from common_evaluator import CommonEvaluator
22
+
23
+
24
+ class CommonEvaluatorPlot(CommonEvaluator):
25
+ """
26
+ A common evaluation framework for testing LLMs on time series datasets with plot generation.
27
+ """
28
+
29
+ def load_model(self, model_name: str, **pipeline_kwargs) -> pipeline:
30
+ """
31
+ Load a model using transformers pipeline or OpenAI API.
32
+ """
33
+ self.current_model_name = (
34
+ model_name # Track the current model name for formatter selection
35
+ )
36
+ if model_name.startswith("openai-"):
37
+ # Use OpenAI API
38
+ openai_model = model_name.replace("openai-", "")
39
+ return OpenAIPipeline(model_name=openai_model, **pipeline_kwargs)
40
+ print(f"Loading model: {model_name}")
41
+ print(f"Using device: {self.device}")
42
+ # Default pipeline arguments
43
+ default_kwargs = {
44
+ "task": "image-text-to-text",
45
+ "device": self.device,
46
+ "temperature": 0.1,
47
+ }
48
+ default_kwargs.update(pipeline_kwargs)
49
+ pipe = pipeline(model=model_name, **default_kwargs)
50
+ print(f"Model loaded successfully: {model_name}")
51
+ return pipe
52
+
53
+ def load_dataset(
54
+ self,
55
+ dataset_class: Type[Dataset],
56
+ split: str = "test",
57
+ format_sample_str: bool = True,
58
+ max_samples: Optional[int] = None,
59
+ **dataset_kwargs,
60
+ ) -> Dataset:
61
+ """
62
+ Load a dataset with proper formatting.
63
+ """
64
+ print(f"Loading dataset: {dataset_class.__name__}")
65
+ formatter = None
66
+
67
+ # Default dataset arguments
68
+ default_kwargs = {
69
+ "split": split,
70
+ "EOS_TOKEN": "",
71
+ "format_sample_str": format_sample_str,
72
+ "time_series_format_function": formatter,
73
+ }
74
+ # Add max_samples if provided
75
+ if max_samples is not None:
76
+ default_kwargs["max_samples"] = max_samples
77
+
78
+ # Update with provided kwargs
79
+ default_kwargs.update(dataset_kwargs)
80
+
81
+ dataset = dataset_class(**default_kwargs)
82
+ print(f"Loaded {len(dataset)} {split} samples")
83
+
84
+ return dataset
85
+
86
+ def evaluate_multiple_models(
87
+ self,
88
+ model_names: List[str],
89
+ dataset_classes: List[Type[Dataset]],
90
+ evaluation_functions: Dict[str, Callable[[str, str], Dict[str, Any]]],
91
+ plot_functions: Optional[Dict[str, Callable[[Any], str]]] = None,
92
+ max_samples: Optional[int] = None,
93
+ **pipeline_kwargs,
94
+ ) -> pd.DataFrame:
95
+ """
96
+ Evaluate multiple models on multiple datasets with plotting support.
97
+
98
+ Args:
99
+ model_names: List of model names to evaluate
100
+ dataset_classes: List of dataset classes to evaluate on
101
+ evaluation_functions: Dictionary mapping dataset class names to evaluation functions
102
+ plot_functions: Optional dict mapping dataset class names to plot functions
103
+ max_samples: Maximum number of samples per evaluation
104
+ **pipeline_kwargs: Additional arguments for model pipeline
105
+ Returns:
106
+ DataFrame with results for all model-dataset combinations
107
+ """
108
+ all_results = []
109
+
110
+ # Generate filename once at the beginning
111
+ import os
112
+
113
+ current_dir = os.path.dirname(os.path.abspath(__file__))
114
+ results_dir = os.path.join(current_dir, "..", "results", "baseline")
115
+ os.makedirs(results_dir, exist_ok=True)
116
+ df_filename = os.path.join(results_dir, "evaluation_results.csv")
117
+ print(f"Results will be saved to: {df_filename}")
118
+ # Load existing results if file exists
119
+ existing_df = None
120
+ if os.path.exists(df_filename):
121
+ try:
122
+ existing_df = pd.read_csv(df_filename)
123
+ print(f"Found existing results file with {len(existing_df)} entries")
124
+ except Exception as e:
125
+ print(f"Warning: Could not read existing results file: {e}")
126
+
127
+ for model_name in model_names:
128
+ for dataset_class in dataset_classes:
129
+ dataset_name = dataset_class.__name__
130
+
131
+ if dataset_name not in evaluation_functions:
132
+ print(f"Warning: No evaluation function found for {dataset_name}")
133
+ continue
134
+
135
+ # Check if this model-dataset combination already exists in results
136
+ if existing_df is not None:
137
+ existing_result = existing_df[
138
+ (existing_df["model"] == model_name)
139
+ & (existing_df["dataset"] == dataset_name)
140
+ ]
141
+ if not existing_result.empty:
142
+ print(
143
+ f"⏭️ Skipping {model_name} on {dataset_name} (already evaluated)"
144
+ )
145
+ continue
146
+
147
+ evaluation_function = evaluation_functions[dataset_name]
148
+ plot_fn = None
149
+ if plot_functions is not None and dataset_name in plot_functions:
150
+ plot_fn = plot_functions[dataset_name]
151
+ else:
152
+ print(f"Warning: No plot function found for {dataset_name}")
153
+
154
+ print(f"\n{'=' * 80}")
155
+ print(f"Evaluating {model_name} on {dataset_name}")
156
+ print(f"{'=' * 80}")
157
+
158
+ try:
159
+ results = self.evaluate_model_on_dataset(
160
+ model_name=model_name,
161
+ dataset_class=dataset_class,
162
+ evaluation_function=evaluation_function,
163
+ plot_function=plot_fn,
164
+ max_samples=max_samples,
165
+ **pipeline_kwargs,
166
+ )
167
+
168
+ # Extract key metrics for DataFrame
169
+ row = {
170
+ "model": model_name,
171
+ "dataset": dataset_name,
172
+ "total_samples": results["total_samples"],
173
+ "successful_inferences": results["successful_inferences"],
174
+ "success_rate": results["success_rate"],
175
+ }
176
+ # Add specific metrics
177
+ if results["metrics"]:
178
+ for metric_name, metric_values in results["metrics"].items():
179
+ if isinstance(metric_values, (int, float)):
180
+ row[metric_name] = metric_values
181
+ else:
182
+ row[metric_name] = str(metric_values)
183
+
184
+ all_results.append(row)
185
+
186
+ # Combine with existing results and save
187
+ current_df = pd.DataFrame(all_results)
188
+ if existing_df is not None:
189
+ # Append new results
190
+ final_df = pd.concat(
191
+ [existing_df, current_df], ignore_index=True
192
+ )
193
+ else:
194
+ final_df = current_df
195
+
196
+ final_df.to_csv(df_filename, index=False)
197
+ print(f"✅ Results updated: {df_filename}")
198
+
199
+ except Exception as e:
200
+ print(f"Error evaluating {model_name} on {dataset_name}: {e}")
201
+ all_results.append(
202
+ {
203
+ "model": model_name,
204
+ "dataset": dataset_name,
205
+ "status": "Failed",
206
+ }
207
+ )
208
+
209
+ # Save DataFrame even after errors
210
+ current_df = pd.DataFrame(all_results)
211
+ if existing_df is not None:
212
+ final_df = pd.concat(
213
+ [existing_df, current_df], ignore_index=True
214
+ )
215
+ else:
216
+ final_df = current_df
217
+ final_df.to_csv(df_filename, index=False)
218
+ print(f"⚠️ Results updated (with error): {df_filename}")
219
+ print(f"\nFinal results saved to: {df_filename}")
220
+ return final_df
221
+
222
+ def evaluate_model_on_dataset(
223
+ self,
224
+ model_name: str,
225
+ dataset_class: Type[Dataset],
226
+ evaluation_function: Callable[[str, str], Dict[str, Any]],
227
+ plot_function: Optional[Callable[[Any], str]] = None,
228
+ max_samples: Optional[int] = None,
229
+ **pipeline_kwargs,
230
+ ) -> Dict[str, Any]:
231
+ """
232
+ Evaluate a model on a dataset using a custom evaluation function.
233
+
234
+ Args:
235
+ model_name: Name of the model to evaluate
236
+ dataset_class: Dataset class to use
237
+ evaluation_function: Function that takes (ground_truth, prediction) and returns metrics
238
+ max_samples: Maximum number of samples to evaluate (None for all)
239
+ **pipeline_kwargs: Additional arguments for model pipeline
240
+
241
+ Returns:
242
+ Dictionary containing evaluation results
243
+ """
244
+ print(
245
+ f"Starting evaluation with model {model_name} on dataset {dataset_class.__name__}"
246
+ )
247
+ print("=" * 60)
248
+
249
+ # Load model
250
+ pipe = self.load_model(model_name, **pipeline_kwargs)
251
+
252
+ # Load dataset
253
+ dataset = self.load_dataset(
254
+ dataset_class, format_sample_str=False, max_samples=max_samples
255
+ )
256
+
257
+ # Limit samples if specified
258
+ if max_samples is not None:
259
+ dataset_size = min(len(dataset), max_samples)
260
+ print(f"Processing first {dataset_size} samples...")
261
+ else:
262
+ dataset_size = len(dataset)
263
+ print(f"Processing all {dataset_size} samples...")
264
+ # Initialize tracking
265
+ total_samples = 0
266
+ successful_inferences = 0
267
+ all_metrics = []
268
+ results = []
269
+ first_error_printed = False # Track if we've printed the first error
270
+
271
+ print("\nRunning inference...")
272
+ print("=" * 80)
273
+
274
+ # Get max_new_tokens for generation (default 1000)
275
+ max_new_tokens = pipeline_kwargs.pop("max_new_tokens", 1000)
276
+
277
+ for idx in tqdm(range(dataset_size), desc="Processing samples"):
278
+ try:
279
+ sample = dataset[idx]
280
+ plot_data = None
281
+
282
+ if isinstance(sample, dict) and "time_series" in sample:
283
+ plot_data = plot_function(sample["time_series"])
284
+ else:
285
+ raise ValueError(
286
+ f"Sample {sample} does not contain 'time_series' key"
287
+ )
288
+
289
+ target_answer = sample["answer"]
290
+ input_text = sample["pre_prompt"] + sample["post_prompt"]
291
+
292
+ # Generate prediction
293
+ if isinstance(pipe, OpenAIPipeline):
294
+ outputs = pipe(
295
+ input_text,
296
+ max_new_tokens=max_new_tokens,
297
+ return_full_text=False,
298
+ plot_data=plot_data,
299
+ )
300
+ else: # For Hugging Face pipelines, convert plot_data (base64) to PIL and pass via images
301
+ try:
302
+ img_bytes = base64.b64decode(plot_data)
303
+ img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
304
+
305
+ # Check if this is a pretrained model (pt) vs instruction-tuned (it)
306
+ if "pt" in model_name.lower() and "gemma" in model_name.lower():
307
+ # For pretrained Gemma models, use model and processor directly
308
+ # to avoid pipeline issues with chat templates
309
+
310
+ try:
311
+ outputs = pipe(
312
+ text=f"{sample['pre_prompt']} <start_of_image> {sample['post_prompt']}",
313
+ images=img,
314
+ max_new_tokens=max_new_tokens,
315
+ return_full_text=False,
316
+ )
317
+ except Exception as e:
318
+ raise RuntimeError(
319
+ f"Failed to call pretrained pipeline: {e}"
320
+ )
321
+ else:
322
+ # For instruction-tuned models, use chat template format
323
+ messages = [
324
+ {
325
+ "role": "user",
326
+ "content": [
327
+ {"type": "image", "image": img},
328
+ {"type": "text", "text": input_text},
329
+ ],
330
+ }
331
+ ]
332
+ outputs = pipe(
333
+ text=messages,
334
+ max_new_tokens=max_new_tokens,
335
+ return_full_text=False,
336
+ )
337
+ except Exception as e:
338
+ raise RuntimeError(f"Failed to decode plot image: {e}")
339
+
340
+ # Extract generated text
341
+ if outputs and len(outputs) > 0:
342
+ generated_text = outputs[0]["generated_text"].strip()
343
+ successful_inferences += 1
344
+
345
+ # Evaluate using custom function (optionally with sample)
346
+ try:
347
+ import inspect
348
+
349
+ sig = inspect.signature(evaluation_function)
350
+ if len(sig.parameters) >= 3:
351
+ metrics = evaluation_function(
352
+ target_answer, generated_text, sample
353
+ )
354
+ else:
355
+ metrics = evaluation_function(target_answer, generated_text)
356
+ except Exception:
357
+ # Fallback to 2-arg call
358
+ metrics = evaluation_function(target_answer, generated_text)
359
+ all_metrics.append(metrics)
360
+ # Store detailed results
361
+ result = {
362
+ "sample_idx": idx,
363
+ "input_text": input_text,
364
+ "target_answer": target_answer,
365
+ "generated_answer": generated_text,
366
+ "metrics": metrics,
367
+ }
368
+ results.append(result)
369
+ # Print progress for first few samples
370
+ if idx < 10:
371
+ print(f"\nSAMPLE {idx + 1}:")
372
+ print(f"PROMPT: {input_text}...")
373
+ print(f"TARGET: {target_answer}")
374
+ print(f"PREDICTION: {generated_text}")
375
+ print(f"METRICS: {metrics}")
376
+ print("=" * 80)
377
+
378
+ # Print first error for debugging
379
+ if not first_error_printed and metrics.get("accuracy", 1) == 0:
380
+ print(f"\n❌ FIRST ERROR (Sample {idx + 1}):")
381
+ print(f"TARGET: {target_answer}")
382
+ print(f"PREDICTION: {generated_text}")
383
+ print("=" * 80)
384
+ first_error_printed = True
385
+ else:
386
+ raise ValueError(f"Unexpectedly found empty outputs")
387
+
388
+ total_samples += 1
389
+
390
+ # Save results every 100 samples
391
+ if total_samples % 50 == 0:
392
+ # Calculate current aggregate metrics
393
+ current_aggregate_metrics = (
394
+ self._aggregate_metrics(all_metrics) if all_metrics else {}
395
+ )
396
+ current_success_rate = (
397
+ successful_inferences / total_samples
398
+ if total_samples > 0
399
+ else 0.0
400
+ )
401
+
402
+ # Prepare intermediate results
403
+ intermediate_results = {
404
+ "model_name": model_name,
405
+ "dataset_name": dataset_class.__name__,
406
+ "total_samples": total_samples,
407
+ "successful_inferences": successful_inferences,
408
+ "success_rate": current_success_rate,
409
+ "metrics": current_aggregate_metrics,
410
+ "detailed_results": results,
411
+ }
412
+
413
+ # Save intermediate results
414
+ self._save_results(intermediate_results)
415
+ print(
416
+ f"💾 Intermediate results saved after {total_samples} samples"
417
+ )
418
+
419
+ except Exception as e:
420
+ print(f"Error processing sample {idx}: {e}")
421
+ total_samples += 1
422
+ continue
423
+ # Calculate aggregate metrics
424
+ if successful_inferences > 0:
425
+ # Aggregate metrics across all samples
426
+ aggregate_metrics = self._aggregate_metrics(all_metrics)
427
+
428
+ # Calculate success rate
429
+ success_rate = successful_inferences / total_samples
430
+
431
+ # Prepare final results
432
+ final_results = {
433
+ "model_name": model_name,
434
+ "dataset_name": dataset_class.__name__,
435
+ "total_samples": total_samples,
436
+ "successful_inferences": successful_inferences,
437
+ "success_rate": success_rate,
438
+ "metrics": aggregate_metrics,
439
+ "detailed_results": results,
440
+ }
441
+
442
+ # Print summary
443
+ self._print_summary(final_results)
444
+
445
+ # Save results
446
+ self._save_results(final_results)
447
+
448
+ return final_results
449
+ else:
450
+ print("❌ No successful inferences completed!")
451
+ return {
452
+ "model_name": model_name,
453
+ "dataset_name": dataset_class.__name__,
454
+ "total_samples": total_samples,
455
+ "successful_inferences": 0,
456
+ "success_rate": 0.0,
457
+ "metrics": {},
458
+ "detailed_results": [],
459
+ }
code/OpenTSLM/evaluation/baseline/eval.sh ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+
3
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
4
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
5
+ #
6
+ # SPDX-License-Identifier: MIT
7
+
8
+ set -euo pipefail
9
+
10
+ # List of models to evaluate
11
+ models=(
12
+ "google/gemma-3-270m"
13
+ "google/gemma-3-1b-pt"
14
+ "meta-llama/Llama-3.2-1B"
15
+ "meta-llama/Llama-3.2-3B"
16
+
17
+ )
18
+
19
+ #echo "Starting evaluation of ${#models[@]} models on TSQA dataset..."
20
+ #for model in "${models[@]}"; do
21
+ # echo "Evaluating $model on TSQA..."
22
+ # python evaluate_tsqa.py "$model"
23
+ #done
24
+
25
+
26
+ echo "Starting evaluation of ${#models[@]} models on HAR dataset..."
27
+ for model in "${models[@]}"; do
28
+ echo "Evaluating $model on PAMAP..."
29
+ python evaluate_har.py "$model"
30
+ done
31
+
32
+ #echo "Starting evaluation of ${#models[@]} models on SleepEDF CoT dataset..."
33
+ #for model in "${models[@]}"; do
34
+ # echo "Evaluating $model on SleepEDF CoT..."
35
+ # python evaluate_sleep_cot.py "$model"
36
+ #done
37
+
38
+ echo "All evaluations completed!"
code/OpenTSLM/evaluation/baseline/eval_remaining.sh ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+
3
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
4
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
5
+ #
6
+ # SPDX-License-Identifier: MIT
7
+
8
+ python evaluate_tsqa.py "google/gemma-3-270m"
9
+ python evaluate_sleep_cot.py "google/gemma-3-270m"
10
+
11
+ python evaluate_tsqa.py "google/gemma-3-270m"
12
+ python evaluate_sleep_cot.py "google/gemma-3-270m"
13
+ python evaluate_har.py "google/gemma-3-1b-pt"
14
+
15
+ python evaluate_ecg_qa.py "google/gemma-3-270m"
16
+ python evaluate_ecg_qa.py "google/gemma-3-1b-pt"
17
+ python evaluate_ecg_qa.py "meta-llama/Llama-3.2-1B"
18
+ python evaluate_ecg_qa.py "meta-llama/Llama-3.2-3B"
code/OpenTSLM/evaluation/baseline/evaluate_all.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ from typing import Dict, Any, Callable
7
+ from common_evaluator import CommonEvaluator
8
+ from opentslm.time_series_datasets.TSQADataset import TSQADataset
9
+ from opentslm.time_series_datasets.pamap2.PAMAP2AccQADataset import PAMAP2AccQADataset
10
+ from opentslm.time_series_datasets.pamap2.PAMAP2CoTQADataset import PAMAP2CoTQADataset
11
+
12
+ # Import evaluation functions
13
+ from evaluate_tsqa import evaluate_tsqa
14
+ from evaluate_pamap import evaluate_pamap_acc, evaluate_pamap_cot
15
+
16
+
17
+ def main():
18
+ """Main function to run comprehensive evaluation across all datasets."""
19
+
20
+ # Define models to evaluate
21
+ model_names = [
22
+ "meta-llama/Llama-3.2-1B",
23
+ # Add more models as needed
24
+ # "google/gemma-3n-e2b",
25
+ # "google/gemma-3n-e2b-it",
26
+ # "microsoft/DialoGPT-medium",
27
+ # "gpt2",
28
+ ]
29
+
30
+ # Define datasets to evaluate on
31
+ dataset_classes = [
32
+ TSQADataset,
33
+ PAMAP2AccQADataset,
34
+ PAMAP2CoTQADataset,
35
+ ]
36
+
37
+ # Define evaluation functions
38
+ evaluation_functions = {
39
+ "TSQADataset": evaluate_tsqa,
40
+ "PAMAP2AccQADataset": evaluate_pamap_acc,
41
+ "PAMAP2CoTQADataset": evaluate_pamap_cot,
42
+ }
43
+
44
+ # Initialize evaluator
45
+ evaluator = CommonEvaluator()
46
+
47
+ # Run comprehensive evaluation
48
+ results_df = evaluator.evaluate_multiple_models(
49
+ model_names=model_names,
50
+ dataset_classes=dataset_classes,
51
+ evaluation_functions=evaluation_functions,
52
+ max_samples=50, # Limit for faster testing, set to None for full evaluation
53
+ )
54
+
55
+ print("\n" + "="*80)
56
+ print("COMPREHENSIVE EVALUATION RESULTS")
57
+ print("="*80)
58
+ print(results_df.to_string(index=False))
59
+
60
+ # Print summary statistics
61
+ print("\n" + "="*80)
62
+ print("SUMMARY STATISTICS")
63
+ print("="*80)
64
+
65
+ # Group by dataset and show average metrics
66
+ for dataset in results_df['dataset'].unique():
67
+ dataset_results = results_df[results_df['dataset'] == dataset]
68
+ print(f"\n{dataset}:")
69
+ print(f" Number of models evaluated: {len(dataset_results)}")
70
+
71
+ # Show average accuracy
72
+ if 'accuracy' in dataset_results.columns:
73
+ avg_accuracy = dataset_results['accuracy'].mean()
74
+ print(f" Average accuracy: {avg_accuracy:.1f}%")
75
+
76
+ # Group by model and show average metrics
77
+ print(f"\nBy Model:")
78
+ for model in results_df['model'].unique():
79
+ model_results = results_df[results_df['model'] == model]
80
+ print(f"\n{model}:")
81
+ print(f" Number of datasets evaluated: {len(model_results)}")
82
+
83
+ # Show average accuracy
84
+ if 'accuracy' in model_results.columns:
85
+ avg_accuracy = model_results['accuracy'].mean()
86
+ print(f" Average accuracy: {avg_accuracy:.1f}%")
87
+
88
+ return results_df
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
code/OpenTSLM/evaluation/baseline/evaluate_ecg_qa.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ import re
7
+ import sys
8
+ from typing import Dict, Any, List, Tuple
9
+
10
+
11
+ from common_evaluator import CommonEvaluator
12
+ from opentslm.time_series_datasets.ecg_qa.ECGQACoTQADataset import ECGQACoTQADataset
13
+
14
+
15
+ def extract_answer(text: str) -> str:
16
+ """
17
+ Extract the final answer from model text, following the parser rules:
18
+ - If "Answer: " present, take substring after the last occurrence
19
+ - Strip any special end tokens like <|...|> or <eos>
20
+ - Trim trailing periods and whitespace
21
+ """
22
+ if text is None:
23
+ return ""
24
+ if "Answer: " not in text:
25
+ return text.strip()
26
+ answer = text.split("Answer: ")[-1].strip()
27
+ answer = re.sub(r"<\|.*?\|>|<eos>$", "", answer).strip()
28
+ answer = re.sub(r"\.$", "", answer).strip()
29
+ return answer
30
+
31
+
32
+ def normalize_label(label: str) -> str:
33
+ """Lowercase, strip, and remove trailing punctuation to match parser behavior."""
34
+ if label is None:
35
+ return ""
36
+ return label.lower().strip().rstrip(".,!?;:")
37
+
38
+
39
+ def evaluate_ecg_metrics(
40
+ ground_truth: str, prediction: str, sample: Dict[str, Any] | None = None
41
+ ) -> Dict[str, Any]:
42
+ """
43
+ Evaluate ECG-QA CoT predictions using per-template answers from CSV.
44
+ Normalization matches the parser in evaluation/opentslm/ecg_qa_cot/parse_ecg_qa_cot_data.py.
45
+ """
46
+ # Extract answers
47
+ pred_raw = extract_answer(prediction)
48
+ gt_raw = extract_answer(ground_truth)
49
+
50
+ # Normalize
51
+ pred_norm = normalize_label(pred_raw)
52
+ gt_norm = normalize_label(gt_raw)
53
+
54
+ # Per-template supported answers (strict)
55
+ if not isinstance(sample, dict):
56
+ print(f"DEBUG: Sample type: {type(sample)}")
57
+ print(f"DEBUG: Sample content: {sample}")
58
+ raise ValueError(
59
+ "Sample must be a dict containing 'template_id' for ECG-QA evaluation"
60
+ )
61
+
62
+ template_id = sample.get("template_id") or sample.get("cot_template_id")
63
+ if template_id is None:
64
+ print(f"DEBUG: Sample keys: {sample.keys()}")
65
+ print(f"DEBUG: Sample content: {sample}")
66
+ raise ValueError("Missing 'template_id' in sample for ECG-QA evaluation")
67
+
68
+ possible_answers = ECGQACoTQADataset.get_possible_answers_for_template(
69
+ int(template_id)
70
+ )
71
+ if not possible_answers:
72
+ raise ValueError(f"No possible answers found for template_id={template_id}")
73
+
74
+ possible_answers_lower = [a.lower().strip() for a in possible_answers]
75
+
76
+ # Supported flags: restrict to template answers strictly
77
+ pred_supported = pred_norm in possible_answers_lower
78
+ gt_supported = gt_norm in possible_answers_lower
79
+
80
+ # Exact match
81
+ is_correct = int(pred_norm == gt_norm)
82
+
83
+ # For single-label exact-match, precision=recall=F1=accuracy per-sample
84
+ f1 = float(is_correct)
85
+
86
+ return {
87
+ "accuracy": is_correct,
88
+ "f1_score": f1,
89
+ "precision": f1,
90
+ "recall": f1,
91
+ "prediction_normalized": pred_norm,
92
+ "ground_truth_normalized": gt_norm,
93
+ "prediction_supported": pred_supported,
94
+ "ground_truth_supported": gt_supported,
95
+ "template_id": template_id,
96
+ "possible_answers": possible_answers,
97
+ }
98
+
99
+
100
+ # --- Parser-matching aggregation helpers ---
101
+
102
+
103
+ def _calculate_template_f1_stats(data_points: List[Dict[str, Any]]) -> Dict[str, Any]:
104
+ if not data_points:
105
+ return {}
106
+
107
+ from collections import defaultdict
108
+
109
+ template_groups: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
110
+ for point in data_points:
111
+ template_id = point.get("template_id")
112
+ if template_id is None:
113
+ raise ValueError(f"Missing template_id in data point: {point}")
114
+ template_groups[int(template_id)].append(point)
115
+
116
+ template_stats: Dict[int, Dict[str, Any]] = {}
117
+ total_samples = 0
118
+ total_correct = 0
119
+ total_f1_sum = 0.0
120
+
121
+ for template_id, points in template_groups.items():
122
+ if not points:
123
+ continue
124
+
125
+ possible_answers = points[0].get("possible_answers", [])
126
+ if not possible_answers:
127
+ raise ValueError(f"No possible answers found for template {template_id}")
128
+
129
+ # Initialize per-class counts
130
+ class_predictions: Dict[str, Dict[str, int]] = {}
131
+ for answer in possible_answers:
132
+ class_predictions[answer.lower()] = {"tp": 0, "fp": 0, "fn": 0}
133
+
134
+ # Count TP/FP/FN
135
+ for p in points:
136
+ gt_class = p.get("ground_truth_normalized", "")
137
+ pred_class = p.get("prediction_normalized", "")
138
+ pred_supported = p.get("prediction_supported", False)
139
+
140
+ if gt_class in class_predictions:
141
+ if pred_class == gt_class:
142
+ class_predictions[gt_class]["tp"] += 1
143
+ else:
144
+ class_predictions[gt_class]["fn"] += 1
145
+ if pred_supported and pred_class in class_predictions:
146
+ class_predictions[pred_class]["fp"] += 1
147
+
148
+ # Per-class and macro-F1
149
+ class_f1_scores: Dict[str, Dict[str, float]] = {}
150
+ template_f1_sum = 0.0
151
+ valid_classes = 0
152
+
153
+ for class_name, counts in class_predictions.items():
154
+ tp = counts["tp"]
155
+ fp = counts["fp"]
156
+ fn = counts["fn"]
157
+ precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
158
+ recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
159
+ f1 = (
160
+ 2 * (precision * recall) / (precision + recall)
161
+ if (precision + recall) > 0
162
+ else 0.0
163
+ )
164
+
165
+ class_f1_scores[class_name] = {
166
+ "f1": f1,
167
+ "precision": precision,
168
+ "recall": recall,
169
+ "tp": tp,
170
+ "fp": fp,
171
+ "fn": fn,
172
+ }
173
+ template_f1_sum += f1
174
+ valid_classes += 1
175
+
176
+ macro_f1 = template_f1_sum / valid_classes if valid_classes > 0 else 0.0
177
+
178
+ template_correct = sum(1 for p in points if p.get("accuracy", False))
179
+ template_accuracy = template_correct / len(points) if points else 0.0
180
+ template_avg_f1 = (
181
+ sum(p.get("f1_score", 0.0) for p in points) / len(points) if points else 0.0
182
+ )
183
+
184
+ template_stats[template_id] = {
185
+ "num_samples": len(points),
186
+ "accuracy": template_accuracy,
187
+ "average_f1": template_avg_f1,
188
+ "macro_f1": macro_f1,
189
+ "class_f1_scores": class_f1_scores,
190
+ "num_classes": valid_classes,
191
+ "correct_predictions": template_correct,
192
+ }
193
+
194
+ total_samples += len(points)
195
+ total_correct += template_correct
196
+ total_f1_sum += template_avg_f1 * len(points)
197
+
198
+ template_macro_f1s = [stats["macro_f1"] for stats in template_stats.values()]
199
+ overall_macro_f1 = (
200
+ sum(template_macro_f1s) / len(template_macro_f1s) if template_macro_f1s else 0.0
201
+ )
202
+ overall_accuracy = total_correct / total_samples if total_samples > 0 else 0.0
203
+ overall_avg_f1 = total_f1_sum / total_samples if total_samples > 0 else 0.0
204
+
205
+ return {
206
+ "overall": {
207
+ "total_samples": total_samples,
208
+ "total_templates": len(template_stats),
209
+ "accuracy": overall_accuracy,
210
+ "average_f1": overall_avg_f1,
211
+ "macro_f1": overall_macro_f1,
212
+ },
213
+ "per_template": template_stats,
214
+ }
215
+
216
+
217
+ def _build_data_points_from_results(
218
+ detailed_results: List[Dict[str, Any]],
219
+ ) -> List[Dict[str, Any]]:
220
+ data_points: List[Dict[str, Any]] = []
221
+ for r in detailed_results:
222
+ m = r.get("metrics", {})
223
+ template_id = m.get("template_id", r.get("template_id"))
224
+ if template_id is None:
225
+ raise ValueError("Missing template_id in detailed result")
226
+ dp = {
227
+ "template_id": int(template_id),
228
+ "accuracy": m.get("accuracy", 0),
229
+ "f1_score": m.get("f1_score", 0.0),
230
+ "precision": m.get("precision", 0.0),
231
+ "recall": m.get("recall", 0.0),
232
+ "prediction_normalized": m.get("prediction_normalized", ""),
233
+ "ground_truth_normalized": m.get("ground_truth_normalized", ""),
234
+ "prediction_supported": m.get("prediction_supported", False),
235
+ "ground_truth_supported": m.get("ground_truth_supported", False),
236
+ "possible_answers": m.get("possible_answers", []),
237
+ }
238
+ if not dp["possible_answers"]:
239
+ raise ValueError(
240
+ f"No possible answers in metrics for template {template_id}"
241
+ )
242
+ data_points.append(dp)
243
+ return data_points
244
+
245
+
246
+ def main():
247
+ """Main function to run ECG-QA CoT evaluation with parser-matching F1 aggregation."""
248
+ if len(sys.argv) != 2:
249
+ print("Usage: python evaluate_ecg_qa.py <model_name>")
250
+ print("Example: python evaluate_ecg_qa.py meta-llama/Llama-3.2-1B")
251
+ sys.exit(1)
252
+
253
+ model_name = sys.argv[1]
254
+
255
+ evaluator = CommonEvaluator()
256
+ # Run single evaluation to keep detailed results for F1 aggregation
257
+ results = evaluator.evaluate_model_on_dataset(
258
+ model_name=model_name,
259
+ dataset_class=ECGQACoTQADataset,
260
+ evaluation_function=evaluate_ecg_metrics,
261
+ max_samples=490,
262
+ max_new_tokens=400,
263
+ )
264
+
265
+ # Build data points and compute parser-matching F1 stats
266
+ detailed_results = results.get("detailed_results", [])
267
+ data_points = _build_data_points_from_results(detailed_results)
268
+ f1_stats = _calculate_template_f1_stats(data_points)
269
+
270
+ # Print parser-like summary
271
+ overall = f1_stats.get("overall", {})
272
+ print("\n" + "=" * 80)
273
+ print("FINAL RESULTS SUMMARY (Parser-matching)")
274
+ print("=" * 80)
275
+ print(f"Total templates: {overall.get('total_templates', 0)}")
276
+ print(f"Average F1 Score: {overall.get('average_f1', 0):.4f}")
277
+ print(f"Macro-F1 Score: {overall.get('macro_f1', 0):.4f}")
278
+
279
+ per_template = f1_stats.get("per_template", {})
280
+ if per_template:
281
+ print(f"\nPer-Template Statistics:")
282
+ for template_id, stats in sorted(per_template.items()):
283
+ print(f" Template {template_id}:")
284
+ print(f" Samples: {stats['num_samples']}")
285
+ print(f" Accuracy: {stats['accuracy']:.4f}")
286
+ print(f" Average F1: {stats['average_f1']:.4f}")
287
+ print(f" Macro-F1: {stats['macro_f1']:.4f}")
288
+
289
+ return f1_stats
290
+
291
+
292
+ if __name__ == "__main__":
293
+ main()
code/OpenTSLM/evaluation/baseline/evaluate_ecg_qa_plot.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ import re
7
+ import sys
8
+ import io
9
+ import base64
10
+ import os
11
+ from datetime import datetime
12
+ from typing import Dict, Any, List, Optional
13
+
14
+ import matplotlib.pyplot as plt
15
+
16
+ from common_evaluator_plot import CommonEvaluatorPlot
17
+ from opentslm.time_series_datasets.ecg_qa.ECGQACoTQADataset import ECGQACoTQADataset
18
+
19
+
20
+ def extract_answer(text: str) -> str:
21
+ """
22
+ Extract the final answer from model text, following the parser rules:
23
+ - If "Answer: " present, take substring after the last occurrence
24
+ - Strip any special end tokens like <|...|> or <eos>
25
+ - Trim trailing periods and whitespace
26
+ """
27
+ if text is None:
28
+ return ""
29
+ if "Answer: " not in text:
30
+ return text.strip()
31
+ answer = text.split("Answer: ")[-1].strip()
32
+ answer = re.sub(r"<\|.*?\|>|<eos>$", "", answer).strip()
33
+ answer = re.sub(r"\.$", "", answer).strip()
34
+ return answer
35
+
36
+
37
+ def normalize_label(label: str) -> str:
38
+ """Lowercase, strip, and remove trailing punctuation to match parser behavior."""
39
+ if label is None:
40
+ return ""
41
+ return label.lower().strip().rstrip(".,!?;:")
42
+
43
+
44
+ def evaluate_ecg_metrics(
45
+ ground_truth: str, prediction: str, sample: Optional[Dict[str, Any]] = None
46
+ ) -> Dict[str, Any]:
47
+ """
48
+ Evaluate ECG-QA CoT predictions using per-template answers from CSV.
49
+ Normalization matches the parser used in evaluate_ecg_qa.py.
50
+ """
51
+ # Extract answers
52
+ pred_raw = extract_answer(prediction)
53
+ gt_raw = extract_answer(ground_truth)
54
+
55
+ # Normalize
56
+ pred_norm = normalize_label(pred_raw)
57
+ gt_norm = normalize_label(gt_raw)
58
+
59
+ # Per-template supported answers (strict)
60
+ if not isinstance(sample, dict):
61
+ raise ValueError(
62
+ "Sample must be a dict containing 'template_id' for ECG-QA evaluation"
63
+ )
64
+
65
+ template_id = sample.get("template_id") or sample.get("cot_template_id")
66
+ if template_id is None:
67
+ raise ValueError("Missing 'template_id' in sample for ECG-QA evaluation")
68
+
69
+ possible_answers = ECGQACoTQADataset.get_possible_answers_for_template(
70
+ int(template_id)
71
+ )
72
+ if not possible_answers:
73
+ raise ValueError(f"No possible answers found for template_id={template_id}")
74
+
75
+ possible_answers_lower = [a.lower().strip() for a in possible_answers]
76
+
77
+ # Supported flags: restrict to template answers strictly
78
+ pred_supported = pred_norm in possible_answers_lower
79
+ gt_supported = gt_norm in possible_answers_lower
80
+
81
+ # Exact match
82
+ is_correct = int(pred_norm == gt_norm)
83
+
84
+ # For single-label exact-match, precision=recall=F1=accuracy per-sample
85
+ f1 = float(is_correct)
86
+
87
+ return {
88
+ "accuracy": is_correct,
89
+ "f1_score": f1,
90
+ "precision": f1,
91
+ "recall": f1,
92
+ "prediction_normalized": pred_norm,
93
+ "ground_truth_normalized": gt_norm,
94
+ "prediction_supported": pred_supported,
95
+ "ground_truth_supported": gt_supported,
96
+ "template_id": template_id,
97
+ "possible_answers": possible_answers,
98
+ }
99
+
100
+
101
+ def generate_ecg_plot(time_series: List[List[float]]) -> str:
102
+ """
103
+ Create a base64 PNG plot for multi-lead ECG time series.
104
+
105
+ - Accepts a list of 1D lists/arrays, one per ECG lead.
106
+ - Renders each lead as a separate subplot with grid and title.
107
+ """
108
+ if time_series is None:
109
+ return None
110
+
111
+ ts_list = list(time_series)
112
+ if not ts_list:
113
+ return None
114
+
115
+ num_series = len(ts_list)
116
+ lead_names = [
117
+ "I",
118
+ "II",
119
+ "III",
120
+ "aVR",
121
+ "aVL",
122
+ "aVF",
123
+ "V1",
124
+ "V2",
125
+ "V3",
126
+ "V4",
127
+ "V5",
128
+ "V6",
129
+ ]
130
+
131
+ # Limit to a reasonable number of subplots; if more, still plot all with generic names
132
+ fig_height = max(3, min(2 + 0.9 * num_series, 20))
133
+ fig, axes = plt.subplots(num_series, 1, figsize=(12, fig_height), sharex=True)
134
+ if num_series == 1:
135
+ axes = [axes]
136
+
137
+ for i, series in enumerate(ts_list):
138
+ axes[i].plot(series, linewidth=1.0)
139
+ axes[i].grid(True, alpha=0.3)
140
+ name = lead_names[i] if i < len(lead_names) else f"Lead {i + 1}"
141
+ axes[i].set_title(f"ECG Lead {name}")
142
+ axes[i].set_ylabel("mV")
143
+ axes[-1].set_xlabel("Time (samples)")
144
+
145
+ plt.tight_layout()
146
+ # Save plot to disk instead of showing it
147
+ current_dir = os.path.dirname(os.path.abspath(__file__))
148
+ results_dir = os.path.join(current_dir, "..", "results", "baseline", "plots")
149
+ os.makedirs(results_dir, exist_ok=True)
150
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
151
+ filename = f"ecg_plot_{timestamp}.png"
152
+ file_path = os.path.join(results_dir, filename)
153
+ plt.savefig(file_path, format="png", bbox_inches="tight", dpi=110)
154
+
155
+ # Also save to buffer for returning base64 to caller
156
+ img_buffer = io.BytesIO()
157
+ plt.savefig(img_buffer, format="png", bbox_inches="tight", dpi=110)
158
+ plt.close()
159
+ img_buffer.seek(0)
160
+ image_data = base64.b64encode(img_buffer.getvalue()).decode("utf-8")
161
+ return image_data
162
+
163
+
164
+ def _calculate_template_f1_stats(data_points: List[Dict[str, Any]]) -> Dict[str, Any]:
165
+ if not data_points:
166
+ return {}
167
+
168
+ from collections import defaultdict
169
+
170
+ template_groups: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
171
+ for point in data_points:
172
+ template_id = point.get("template_id")
173
+ if template_id is None:
174
+ raise ValueError(f"Missing template_id in data point: {point}")
175
+ template_groups[int(template_id)].append(point)
176
+
177
+ template_stats: Dict[int, Dict[str, Any]] = {}
178
+ total_samples = 0
179
+ total_correct = 0
180
+ total_f1_sum = 0.0
181
+
182
+ for template_id, points in template_groups.items():
183
+ if not points:
184
+ continue
185
+
186
+ possible_answers = points[0].get("possible_answers", [])
187
+ if not possible_answers:
188
+ raise ValueError(f"No possible answers found for template {template_id}")
189
+
190
+ # Initialize per-class counts
191
+ class_predictions: Dict[str, Dict[str, int]] = {}
192
+ for answer in possible_answers:
193
+ class_predictions[answer.lower()] = {"tp": 0, "fp": 0, "fn": 0}
194
+
195
+ # Count TP/FP/FN
196
+ for p in points:
197
+ gt_class = p.get("ground_truth_normalized", "")
198
+ pred_class = p.get("prediction_normalized", "")
199
+ pred_supported = p.get("prediction_supported", False)
200
+
201
+ if gt_class in class_predictions:
202
+ if pred_class == gt_class:
203
+ class_predictions[gt_class]["tp"] += 1
204
+ else:
205
+ class_predictions[gt_class]["fn"] += 1
206
+ if pred_supported and pred_class in class_predictions:
207
+ class_predictions[pred_class]["fp"] += 1
208
+
209
+ # Per-class and macro-F1
210
+ class_f1_scores: Dict[str, Dict[str, float]] = {}
211
+ template_f1_sum = 0.0
212
+ valid_classes = 0
213
+
214
+ for class_name, counts in class_predictions.items():
215
+ tp = counts["tp"]
216
+ fp = counts["fp"]
217
+ fn = counts["fn"]
218
+ precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
219
+ recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
220
+ f1 = (
221
+ 2 * (precision * recall) / (precision + recall)
222
+ if (precision + recall) > 0
223
+ else 0.0
224
+ )
225
+
226
+ class_f1_scores[class_name] = {
227
+ "f1": f1,
228
+ "precision": precision,
229
+ "recall": recall,
230
+ "tp": tp,
231
+ "fp": fp,
232
+ "fn": fn,
233
+ }
234
+ template_f1_sum += f1
235
+ valid_classes += 1
236
+
237
+ macro_f1 = template_f1_sum / valid_classes if valid_classes > 0 else 0.0
238
+
239
+ template_correct = sum(1 for p in points if p.get("accuracy", False))
240
+ template_accuracy = template_correct / len(points) if points else 0.0
241
+ template_avg_f1 = (
242
+ sum(p.get("f1_score", 0.0) for p in points) / len(points) if points else 0.0
243
+ )
244
+
245
+ template_stats[template_id] = {
246
+ "num_samples": len(points),
247
+ "accuracy": template_accuracy,
248
+ "average_f1": template_avg_f1,
249
+ "macro_f1": macro_f1,
250
+ "class_f1_scores": class_f1_scores,
251
+ "num_classes": valid_classes,
252
+ "correct_predictions": template_correct,
253
+ }
254
+
255
+ total_samples += len(points)
256
+ total_correct += template_correct
257
+ total_f1_sum += template_avg_f1 * len(points)
258
+
259
+ template_macro_f1s = [stats["macro_f1"] for stats in template_stats.values()]
260
+ overall_macro_f1 = (
261
+ sum(template_macro_f1s) / len(template_macro_f1s) if template_macro_f1s else 0.0
262
+ )
263
+ overall_accuracy = total_correct / total_samples if total_samples > 0 else 0.0
264
+ overall_avg_f1 = total_f1_sum / total_samples if total_samples > 0 else 0.0
265
+
266
+ return {
267
+ "overall": {
268
+ "total_samples": total_samples,
269
+ "total_templates": len(template_stats),
270
+ "accuracy": overall_accuracy,
271
+ "average_f1": overall_avg_f1,
272
+ "macro_f1": overall_macro_f1,
273
+ },
274
+ "per_template": template_stats,
275
+ }
276
+
277
+
278
+ def _build_data_points_from_results(
279
+ detailed_results: List[Dict[str, Any]],
280
+ ) -> List[Dict[str, Any]]:
281
+ data_points: List[Dict[str, Any]] = []
282
+ for r in detailed_results:
283
+ m = r.get("metrics", {})
284
+ template_id = m.get("template_id", r.get("template_id"))
285
+ if template_id is None:
286
+ raise ValueError("Missing template_id in detailed result")
287
+ dp = {
288
+ "template_id": int(template_id),
289
+ "accuracy": m.get("accuracy", 0),
290
+ "f1_score": m.get("f1_score", 0.0),
291
+ "precision": m.get("precision", 0.0),
292
+ "recall": m.get("recall", 0.0),
293
+ "prediction_normalized": m.get("prediction_normalized", ""),
294
+ "ground_truth_normalized": m.get("ground_truth_normalized", ""),
295
+ "prediction_supported": m.get("prediction_supported", False),
296
+ "ground_truth_supported": m.get("ground_truth_supported", False),
297
+ "possible_answers": m.get("possible_answers", []),
298
+ }
299
+ if not dp["possible_answers"]:
300
+ raise ValueError(
301
+ f"No possible answers in metrics for template {template_id}"
302
+ )
303
+ data_points.append(dp)
304
+ return data_points
305
+
306
+
307
+ def main():
308
+ """Main function to run ECG-QA CoT evaluation with plotting."""
309
+ if len(sys.argv) != 2:
310
+ print("Usage: python evaluate_ecqqa_plot.py <model_name>")
311
+ print("Example: python evaluate_ecqqa_plot.py meta-llama/Llama-3.2-1B")
312
+ sys.exit(1)
313
+
314
+ model_name = sys.argv[1]
315
+
316
+ evaluator = CommonEvaluatorPlot()
317
+ # Run single evaluation to keep detailed results for F1 aggregation
318
+ results = evaluator.evaluate_model_on_dataset(
319
+ model_name=model_name,
320
+ dataset_class=ECGQACoTQADataset,
321
+ evaluation_function=evaluate_ecg_metrics,
322
+ plot_function=generate_ecg_plot,
323
+ max_samples=10000,
324
+ max_new_tokens=400,
325
+ )
326
+
327
+ # Build data points and compute parser-matching F1 stats
328
+ detailed_results = results.get("detailed_results", [])
329
+ data_points = _build_data_points_from_results(detailed_results)
330
+ f1_stats = _calculate_template_f1_stats(data_points)
331
+
332
+ # Print parser-like summary
333
+ overall = f1_stats.get("overall", {})
334
+ print("\n" + "=" * 80)
335
+ print("FINAL RESULTS SUMMARY (Parser-matching)")
336
+ print("=" * 80)
337
+ print(f"Total templates: {overall.get('total_templates', 0)}")
338
+ print(f"Average F1 Score: {overall.get('average_f1', 0):.4f}")
339
+ print(f"Macro-F1 Score: {overall.get('macro_f1', 0):.4f}")
340
+
341
+ per_template = f1_stats.get("per_template", {})
342
+ if per_template:
343
+ print(f"\nPer-Template Statistics:")
344
+ for template_id, stats in sorted(per_template.items()):
345
+ print(f" Template {template_id}:")
346
+ print(f" Samples: {stats['num_samples']}")
347
+ print(f" Accuracy: {stats['accuracy']:.4f}")
348
+ print(f" Average F1: {stats['average_f1']:.4f}")
349
+ print(f" Macro-F1: {stats['macro_f1']:.4f}")
350
+
351
+ return f1_stats
352
+
353
+
354
+ if __name__ == "__main__":
355
+ main()
code/OpenTSLM/evaluation/baseline/evaluate_har.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ import re
7
+ import sys
8
+ from typing import Dict, Any
9
+
10
+ from opentslm.time_series_datasets.har_cot.HARCoTQADataset import HARCoTQADataset
11
+
12
+
13
+ def extract_label_from_prediction(prediction: str) -> str:
14
+ """
15
+ Extract the label from the model's prediction.
16
+ - If 'Answer:' is present, take everything after the last 'Answer:'
17
+ - Otherwise, take the last word
18
+ - Strips whitespace and punctuation
19
+ """
20
+ pred = prediction.strip()
21
+ # Find the last occurrence of 'Answer:' (case-insensitive)
22
+ match = list(re.finditer(r"answer:\s*", pred, re.IGNORECASE))
23
+ if match:
24
+ # Take everything after the last 'Answer:'
25
+ start = match[-1].end()
26
+ label = pred[start:].strip()
27
+ else:
28
+ # Take the last word
29
+ label = pred.split()[-1] if pred.split() else ""
30
+ # Remove trailing punctuation (e.g., period, comma)
31
+ label = re.sub(r"[\.,;:!?]+$", "", label)
32
+ return label.lower()
33
+
34
+
35
+ def evaluate_har_acc(ground_truth: str, prediction: str) -> Dict[str, Any]:
36
+ """
37
+ <<<<<<< HEAD
38
+ <<<<<<< HEAD
39
+ Evaluate HARAccQADataset predictions against ground truth.
40
+ =======
41
+ Evaluate HARCoTQADataset predictions against ground truth.
42
+ >>>>>>> RealLast/ECG-QA-Integration
43
+ =======
44
+ Evaluate HARCoTQADataset predictions against ground truth.
45
+ >>>>>>> main
46
+ Extracts the label from the end of the model's output and compares to ground truth.
47
+ """
48
+ gt_clean = ground_truth.lower().strip()
49
+ pred_label = extract_label_from_prediction(prediction)
50
+ accuracy = int(gt_clean == pred_label)
51
+ return {"accuracy": accuracy}
52
+
53
+
54
+ def main():
55
+ """Main function to run HAR evaluation."""
56
+ if len(sys.argv) != 2:
57
+ print("Usage: python evaluate_har.py <model_name>")
58
+ print("Example: python evaluate_har.py meta-llama/Llama-3.2-1B")
59
+ sys.exit(1)
60
+
61
+ model_name = sys.argv[1]
62
+
63
+ dataset_classes = [HARCoTQADataset]
64
+ evaluation_functions = {
65
+ "HARCoTQADataset": evaluate_har_acc,
66
+ }
67
+ evaluator = CommonEvaluator()
68
+ results_df = evaluator.evaluate_multiple_models(
69
+ model_names=[model_name],
70
+ dataset_classes=dataset_classes,
71
+ evaluation_functions=evaluation_functions,
72
+ max_samples=None, # Limit for faster testing, set to None for full evaluation,
73
+ max_new_tokens=400,
74
+ )
75
+ print("\n" + "=" * 80)
76
+ print("FINAL RESULTS SUMMARY")
77
+ print("=" * 80)
78
+ print(results_df.to_string(index=False))
79
+ return results_df
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
code/OpenTSLM/evaluation/baseline/evaluate_har_plot.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ import re
7
+ import sys
8
+ import argparse
9
+ import io
10
+ import base64
11
+ from typing import Dict, Any
12
+
13
+ import matplotlib.pyplot as plt
14
+
15
+ from common_evaluator_plot import CommonEvaluatorPlot
16
+ from opentslm.time_series_datasets.pamap2.PAMAP2AccQADataset import PAMAP2AccQADataset
17
+ from opentslm.time_series_datasets.har_cot.HARAccQADataset import HARAccQADataset
18
+
19
+ def extract_label_from_prediction(prediction: str) -> str:
20
+ """
21
+ Extract the label from the model's prediction.
22
+ - If 'Answer:' is present, take everything after the last 'Answer:'
23
+ - Otherwise, take the last word
24
+ - Strips whitespace and punctuation
25
+ """
26
+ pred = prediction.strip()
27
+ # Find the last occurrence of 'Answer:' (case-insensitive)
28
+ match = list(re.finditer(r'answer:\s*', pred, re.IGNORECASE))
29
+ if match:
30
+ # Take everything after the last 'Answer:'
31
+ start = match[-1].end()
32
+ label = pred[start:].strip()
33
+ else:
34
+ # Take the last word
35
+ label = pred.split()[-1] if pred.split() else ''
36
+ # Remove trailing punctuation (e.g., period, comma)
37
+ label = re.sub(r'[\.,;:!?]+$', '', label)
38
+ return label.lower()
39
+
40
+
41
+ def evaluate_har_acc(ground_truth: str, prediction: str) -> Dict[str, Any]:
42
+ """
43
+ Evaluate HARAccQADataset predictions against ground truth.
44
+ Extracts the label from the end of the model's output and compares to ground truth.
45
+ """
46
+ gt_clean = ground_truth.lower().strip()
47
+ pred_label = extract_label_from_prediction(prediction)
48
+ accuracy = int(gt_clean == pred_label)
49
+ return {"accuracy": accuracy}
50
+
51
+
52
+ def generate_time_series_plot(time_series) -> str:
53
+ """
54
+ Create a base64 PNG plot from a list/tuple of 1D numpy arrays (e.g., [x, y, z]).
55
+ """
56
+ if time_series is None:
57
+ return None
58
+ ts_list = list(time_series)
59
+
60
+ num_series = len(ts_list)
61
+ fig, axes = plt.subplots(num_series, 1, figsize=(10, 4 * num_series), sharex=True)
62
+ if num_series == 1:
63
+ axes = [axes]
64
+
65
+ axis_names = {0: 'X-axis', 1: 'Y-axis', 2: 'Z-axis'}
66
+ for i, series in enumerate(ts_list):
67
+ axes[i].plot(series, marker='o', linestyle='-', markersize=0)
68
+ axes[i].grid(True, alpha=0.3)
69
+ axes[i].set_title(f"Accelerometer - {axis_names.get(i, f'Axis {i+1}')}" )
70
+
71
+ plt.tight_layout()
72
+
73
+ img_buffer = io.BytesIO()
74
+ plt.savefig(img_buffer, format='png', bbox_inches='tight', dpi=100)
75
+ plt.close()
76
+ img_buffer.seek(0)
77
+ image_data = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
78
+ return image_data
79
+
80
+
81
+ def main():
82
+ """Main function to run HAR evaluation."""
83
+ if len(sys.argv) != 2:
84
+ print("Usage: python evaluate_har_plot.py <model_name>")
85
+ print("Example: python evaluate_har_plot.py meta-llama/Llama-3.2-1B")
86
+ sys.exit(1)
87
+
88
+ model_name = sys.argv[1]
89
+
90
+ dataset_classes = [HARAccQADataset]
91
+ evaluation_functions = {
92
+ "HARAccQADataset": evaluate_har_acc,
93
+ }
94
+ evaluator = CommonEvaluatorPlot()
95
+ plot_functions = {
96
+ "HARAccQADataset": generate_time_series_plot,
97
+ }
98
+ results_df = evaluator.evaluate_multiple_models(
99
+ model_names=[model_name],
100
+ dataset_classes=dataset_classes,
101
+ evaluation_functions=evaluation_functions,
102
+ plot_functions=plot_functions,
103
+ max_samples=None, # Limit for faster testing, set to None for full evaluation,
104
+ max_new_tokens=400,
105
+ )
106
+ print("\n" + "="*80)
107
+ print("FINAL RESULTS SUMMARY")
108
+ print("="*80)
109
+ print(results_df.to_string(index=False))
110
+ return results_df
111
+
112
+ if __name__ == "__main__":
113
+ main()
code/OpenTSLM/evaluation/baseline/evaluate_pamap.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ import re
7
+ import sys
8
+ from typing import Dict, Any
9
+
10
+ from common_evaluator import CommonEvaluator
11
+ from opentslm.time_series_datasets.pamap2.PAMAP2AccQADataset import PAMAP2AccQADataset
12
+
13
+
14
+ def extract_label_from_prediction(prediction: str) -> str:
15
+ """
16
+ Extract the label from the model's prediction.
17
+ - If 'Answer:' is present, take everything after the last 'Answer:'
18
+ - Otherwise, take the last word
19
+ - Strips whitespace and punctuation
20
+ """
21
+ pred = prediction.strip()
22
+ # Find the last occurrence of 'Answer:' (case-insensitive)
23
+ match = list(re.finditer(r'answer:\s*', pred, re.IGNORECASE))
24
+ if match:
25
+ # Take everything after the last 'Answer:'
26
+ start = match[-1].end()
27
+ label = pred[start:].strip()
28
+ else:
29
+ # Take the last word
30
+ label = pred.split()[-1] if pred.split() else ''
31
+ # Remove trailing punctuation (e.g., period, comma)
32
+ label = re.sub(r'[\.,;:!?]+$', '', label)
33
+ return label.lower()
34
+
35
+
36
+ def evaluate_pamap_acc(ground_truth: str, prediction: str) -> Dict[str, Any]:
37
+ """
38
+ Evaluate PAMAP2AccQADataset predictions against ground truth.
39
+ Extracts the label from the end of the model's output and compares to ground truth.
40
+ """
41
+ gt_clean = ground_truth.lower().strip()
42
+ pred_label = extract_label_from_prediction(prediction)
43
+ accuracy = int(gt_clean == pred_label)
44
+ return {"accuracy": accuracy}
45
+
46
+
47
+ def main():
48
+ """Main function to run PAMAP evaluation."""
49
+ if len(sys.argv) != 2:
50
+ print("Usage: python evaluate_pamap.py <model_name>")
51
+ print("Example: python evaluate_pamap.py meta-llama/Llama-3.2-1B")
52
+ sys.exit(1)
53
+
54
+ model_name = sys.argv[1]
55
+
56
+ dataset_classes = [PAMAP2AccQADataset]
57
+ evaluation_functions = {
58
+ "PAMAP2AccQADataset": evaluate_pamap_acc,
59
+ }
60
+ evaluator = CommonEvaluator()
61
+ results_df = evaluator.evaluate_multiple_models(
62
+ model_names=[model_name],
63
+ dataset_classes=dataset_classes,
64
+ evaluation_functions=evaluation_functions,
65
+ max_samples=None, # Limit for faster testing, set to None for full evaluation,
66
+ max_new_tokens=400,
67
+ )
68
+ print("\n" + "="*80)
69
+ print("FINAL RESULTS SUMMARY")
70
+ print("="*80)
71
+ print(results_df.to_string(index=False))
72
+ return results_df
73
+
74
+ if __name__ == "__main__":
75
+ main()
code/OpenTSLM/evaluation/baseline/evaluate_sleep_cot.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ import re
7
+ import sys
8
+ from typing import Dict, Any
9
+
10
+ from common_evaluator import CommonEvaluator
11
+ from opentslm.time_series_datasets.sleep.SleepEDFCoTQADataset import SleepEDFCoTQADataset
12
+
13
+
14
+ def extract_sleep_stage_from_prediction(prediction: str) -> str:
15
+ """
16
+ Extract the sleep stage label from the model's prediction.
17
+ - If 'Answer:' is present, take everything after the last 'Answer:'
18
+ - Otherwise, take the last word/phrase
19
+ - Strips whitespace and punctuation
20
+ """
21
+ pred = prediction.strip()
22
+ # Find the last occurrence of 'Answer:' (case-insensitive)
23
+ match = list(re.finditer(r'answer:\s*', pred, re.IGNORECASE))
24
+ if match:
25
+ # Take everything after the last 'Answer:'
26
+ start = match[-1].end()
27
+ label = pred[start:].strip()
28
+ else:
29
+ # Take the last word/phrase (sleep stages can be multi-word)
30
+ words = pred.split()
31
+ if len(words) >= 2 and words[-2].lower() in ['non-rem', 'rem']:
32
+ # Handle multi-word sleep stages like "Non-REM stage 1", "REM sleep"
33
+ label = ' '.join(words[-2:])
34
+ else:
35
+ # Single word stage
36
+ label = words[-1] if words else ''
37
+ # Look for "Answer: [answer]" pattern with more precise matching
38
+ answer_match = re.search(r'Answer:\s*(.+?)(?:\.|$)', pred, re.IGNORECASE)
39
+ if answer_match:
40
+ label = answer_match.group(1).strip()
41
+ # Remove trailing period if present
42
+ if label.endswith('.'):
43
+ label = label[:-1]
44
+ return label.strip().lower()
45
+ # Remove trailing punctuation (e.g., period, comma)
46
+ label = re.sub(r'[\.,;:!?]+$', '', label)
47
+ return label.strip().lower()
48
+
49
+
50
+ def evaluate_sleep_cot(ground_truth: str, prediction: str) -> Dict[str, Any]:
51
+ """
52
+ Evaluate SleepEDFCoTQADataset predictions against ground truth.
53
+ Extracts the sleep stage label from the end of the model's output and compares to ground truth.
54
+ """
55
+ gt_clean = ground_truth.lower().strip()
56
+ pred_label = extract_sleep_stage_from_prediction(prediction)
57
+ accuracy = int(gt_clean == pred_label)
58
+ return {"accuracy": accuracy}
59
+
60
+
61
+ def main():
62
+ """Main function to run SleepEDF CoT evaluation."""
63
+ if len(sys.argv) != 2:
64
+ print("Usage: python evaluate_sleep_cot.py <model_name>")
65
+ print("Example: python evaluate_sleep_cot.py meta-llama/Llama-3.2-1B")
66
+ sys.exit(1)
67
+
68
+ model_name = sys.argv[1]
69
+
70
+ dataset_classes = [SleepEDFCoTQADataset]
71
+ evaluation_functions = {
72
+ "SleepEDFCoTQADataset": evaluate_sleep_cot,
73
+ }
74
+
75
+ evaluator = CommonEvaluator()
76
+ results_df = evaluator.evaluate_multiple_models(
77
+ model_names=[model_name],
78
+ dataset_classes=dataset_classes,
79
+ evaluation_functions=evaluation_functions,
80
+ max_samples=None, # Set to None for full evaluation
81
+ max_new_tokens=1000, # Sleep stage classification might need more tokens for reasoning
82
+ )
83
+
84
+ print("\n" + "="*80)
85
+ print("FINAL RESULTS SUMMARY")
86
+ print("="*80)
87
+ print(results_df.to_string(index=False))
88
+
89
+ return results_df
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
code/OpenTSLM/evaluation/baseline/evaluate_sleep_plot.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ import re
7
+ import sys
8
+ import io
9
+ import base64
10
+ from typing import Dict, Any
11
+
12
+ import matplotlib.pyplot as plt
13
+ import numpy as np
14
+
15
+ from common_evaluator_plot import CommonEvaluatorPlot
16
+ from opentslm.time_series_datasets.sleep.SleepEDFCoTQADataset import SleepEDFCoTQADataset
17
+
18
+
19
+ def extract_label_from_text(text: str) -> str:
20
+ """
21
+ Extract the label from a free-form rationale or prediction text.
22
+ - If 'Answer:' is present (case-insensitive), take everything after the last 'Answer:'
23
+ - Otherwise, take the last word
24
+ - Strip whitespace and trailing punctuation
25
+ - Lowercase for comparison
26
+ """
27
+ if text is None:
28
+ return ""
29
+ pred = text.strip()
30
+ matches = list(re.finditer(r"answer:\s*", pred, re.IGNORECASE))
31
+ if matches:
32
+ start = matches[-1].end()
33
+ label = pred[start:].strip()
34
+ else:
35
+ label = pred.split()[-1] if pred.split() else ""
36
+ label = re.sub(r"[\.,;:!?]+$", "", label)
37
+ return label.lower()
38
+
39
+
40
+ def evaluate_sleep_stage(
41
+ ground_truth_text: str, prediction_text: str
42
+ ) -> Dict[str, Any]:
43
+ """
44
+ Evaluate SleepEDFCoTQADataset predictions against ground truth.
45
+ For SleepEDF, the dataset's "answer" is a rationale ending with 'Answer: <label>'.
46
+ We therefore extract the label from BOTH ground truth and prediction and compare.
47
+ """
48
+ gt_label = extract_label_from_text(ground_truth_text)
49
+ pred_label = extract_label_from_text(prediction_text)
50
+ accuracy = int(gt_label == pred_label)
51
+ return {"accuracy": accuracy, "gt_label": gt_label, "pred_label": pred_label}
52
+
53
+
54
+ def generate_time_series_plot(time_series) -> str:
55
+ """
56
+ Create a base64 PNG plot from one or more time series.
57
+ - Accepts a single 1D array/list or a collection of 1D arrays/lists.
58
+ - If a 2D numpy array is provided, each row is treated as a separate series.
59
+ """
60
+ if time_series is None:
61
+ return None
62
+ ts_list = list(time_series)
63
+
64
+ num_series = len(ts_list)
65
+ fig, axes = plt.subplots(num_series, 1, figsize=(10, 4 * num_series), sharex=True)
66
+ if num_series == 1:
67
+ axes = [axes]
68
+
69
+ axis_names = {0: "EEG", 1: "EOG", 2: "EMG"}
70
+ for i, series in enumerate(ts_list):
71
+ axes[i].plot(series, marker="o", linestyle="-", markersize=0)
72
+ axes[i].grid(True, alpha=0.3)
73
+ axes[i].set_title(f"{axis_names.get(i, f'Axis {i + 1}')}")
74
+
75
+ plt.tight_layout()
76
+
77
+ img_buffer = io.BytesIO()
78
+ plt.savefig(img_buffer, format="png", bbox_inches="tight", dpi=100)
79
+ plt.close()
80
+ img_buffer.seek(0)
81
+ image_data = base64.b64encode(img_buffer.getvalue()).decode("utf-8")
82
+ return image_data
83
+
84
+
85
+ def main():
86
+ """Main function to run SleepEDF evaluation with plotting."""
87
+ if len(sys.argv) != 2:
88
+ print("Usage: python evaluate_sleep_plot.py <model_name>")
89
+ print("Example: python evaluate_sleep_plot.py openai-gpt-4o")
90
+ sys.exit(1)
91
+
92
+ model_name = sys.argv[1]
93
+
94
+ dataset_classes = [SleepEDFCoTQADataset]
95
+ evaluation_functions = {
96
+ "SleepEDFCoTQADataset": evaluate_sleep_stage,
97
+ }
98
+ evaluator = CommonEvaluatorPlot()
99
+ plot_functions = {
100
+ "SleepEDFCoTQADataset": generate_time_series_plot,
101
+ }
102
+
103
+ results_df = evaluator.evaluate_multiple_models(
104
+ model_names=[model_name],
105
+ dataset_classes=dataset_classes,
106
+ evaluation_functions=evaluation_functions,
107
+ plot_functions=plot_functions,
108
+ max_samples=None, # Set to None for full evaluation
109
+ max_new_tokens=400,
110
+ )
111
+
112
+ print("\n" + "=" * 80)
113
+ print("FINAL RESULTS SUMMARY")
114
+ print("=" * 80)
115
+ print(results_df.to_string(index=False))
116
+ return results_df
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
code/OpenTSLM/evaluation/baseline/evaluate_tsqa.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ import re
7
+ import sys
8
+ from typing import Dict, Any
9
+
10
+ from common_evaluator import CommonEvaluator
11
+ from opentslm.time_series_datasets.TSQADataset import TSQADataset
12
+
13
+
14
+ def evaluate_tsqa(ground_truth: str, prediction: str) -> Dict[str, Any]:
15
+ """
16
+ Evaluate TSQA predictions against ground truth.
17
+
18
+ Args:
19
+ ground_truth: The correct answer
20
+ prediction: The model's prediction
21
+
22
+ Returns:
23
+ Dictionary containing evaluation metrics
24
+ """
25
+ # Clean up strings for comparison
26
+ gt_clean = ground_truth.lower().strip()
27
+ pred_clean = prediction.lower().strip()
28
+
29
+ # Only compare the first 3 characters of the ground truth and prediction,
30
+ # because the ground truth answer is always in the format "(a)", "(b)", or "(c)",
31
+ # but the model might answer with the full answer, e.g. "(a) This time series has a constant volatility."
32
+ gt_clean, pred_clean = gt_clean[:3], pred_clean[:3]
33
+
34
+ # Extract the actual answer from the prediction (everything after "Answer:")
35
+ answer_match = re.search(r'answer:\s*(.+)', pred_clean, re.IGNORECASE)
36
+ if answer_match:
37
+ pred_answer = answer_match.group(1).strip()
38
+ else:
39
+ # If no "Answer:" found, use the entire prediction
40
+ pred_answer = pred_clean
41
+
42
+ # Calculate accuracy (exact match)
43
+ accuracy = int(gt_clean == pred_answer)
44
+
45
+ return {
46
+ "accuracy": accuracy,
47
+ }
48
+
49
+
50
+ def main():
51
+ """Main function to run TSQA evaluation."""
52
+
53
+ if len(sys.argv) != 2:
54
+ print("Usage: python evaluate_tsqa.py <model_name>")
55
+ print("Example: python evaluate_tsqa.py meta-llama/Llama-3.2-1B")
56
+ sys.exit(1)
57
+
58
+ model_name = sys.argv[1]
59
+
60
+ # Define datasets to evaluate on
61
+ dataset_classes = [TSQADataset]
62
+
63
+ # Define evaluation functions
64
+ evaluation_functions = {
65
+ "TSQADataset": evaluate_tsqa,
66
+ }
67
+
68
+ # Initialize evaluator
69
+ evaluator = CommonEvaluator()
70
+
71
+ # Run evaluation
72
+ results_df = evaluator.evaluate_multiple_models(
73
+ model_names=[model_name],
74
+ dataset_classes=dataset_classes,
75
+ evaluation_functions=evaluation_functions,
76
+ max_samples=None, # Limit for faster testing, set to None for full evaluation
77
+ max_new_tokens=40,
78
+ )
79
+
80
+ print("\n" + "="*80)
81
+ print("FINAL RESULTS SUMMARY")
82
+ print("="*80)
83
+ print(results_df.to_string(index=False))
84
+
85
+ return results_df
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()
code/OpenTSLM/evaluation/baseline/evaluate_tsqa_plot.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ import re
7
+ import sys
8
+ import io
9
+ import base64
10
+ from typing import Dict, Any
11
+
12
+ import matplotlib.pyplot as plt
13
+
14
+ from common_evaluator_plot import CommonEvaluatorPlot
15
+ from opentslm.time_series_datasets.TSQADataset import TSQADataset
16
+
17
+
18
+ def evaluate_tsqa(ground_truth: str, prediction: str) -> Dict[str, Any]:
19
+ """
20
+ Evaluate TSQA predictions against ground truth.
21
+
22
+ - TSQA answers are in the format "(a)", "(b)", or "(c)".
23
+ - The model might answer with a longer explanation like
24
+ "(a) This time series has a constant volatility.", so we compare only
25
+ the first 3 characters of the answer (e.g., "(a)").
26
+ - If the prediction contains an "Answer:" tag, take everything after it.
27
+ """
28
+ # Clean up strings for comparison
29
+ gt_clean = ground_truth.lower().strip()
30
+ pred_clean = prediction.lower().strip()
31
+
32
+ # Only compare the first 3 characters of the ground truth and prediction
33
+ gt_clean, pred_clean = gt_clean[:3], pred_clean[:3]
34
+
35
+ # Extract the actual answer from the prediction (everything after "Answer:")
36
+ answer_match = re.search(r"answer:\s*(.+)", pred_clean, re.IGNORECASE)
37
+ if answer_match:
38
+ pred_answer = answer_match.group(1).strip()
39
+ else:
40
+ # If no "Answer:" found, use the entire prediction
41
+ pred_answer = pred_clean
42
+
43
+ # Calculate accuracy (exact match)
44
+ accuracy = int(gt_clean == pred_answer)
45
+
46
+ return {
47
+ "accuracy": accuracy,
48
+ }
49
+
50
+
51
+ def generate_time_series_plot(time_series) -> str:
52
+ """
53
+ Create a base64 PNG plot from one or more time series.
54
+
55
+ - TSQA typically has a single 1D time series, but we handle multiple for robustness.
56
+ - Accepts either a single 1D list/array or a list of 1D lists/arrays.
57
+ """
58
+ if time_series is None:
59
+ return None
60
+
61
+ # Ensure we have a list of series
62
+ ts_list = list(time_series)
63
+ # If it's a flat list of numbers, wrap it as a single series
64
+ if ts_list and all(isinstance(x, (int, float)) for x in ts_list):
65
+ ts_list = [ts_list]
66
+
67
+ num_series = len(ts_list)
68
+ fig, axes = plt.subplots(num_series, 1, figsize=(10, 4 * num_series), sharex=True)
69
+ if num_series == 1:
70
+ axes = [axes]
71
+
72
+ for i, series in enumerate(ts_list):
73
+ axes[i].plot(series, marker="o", linestyle="-", markersize=0)
74
+ axes[i].grid(True, alpha=0.3)
75
+ axes[i].set_title(f"Time Series {i + 1}")
76
+ axes[i].set_ylabel("Value")
77
+ axes[-1].set_xlabel("Time")
78
+
79
+ plt.tight_layout()
80
+
81
+ img_buffer = io.BytesIO()
82
+ plt.savefig(img_buffer, format="png", bbox_inches="tight", dpi=100)
83
+ plt.close()
84
+ img_buffer.seek(0)
85
+ image_data = base64.b64encode(img_buffer.getvalue()).decode("utf-8")
86
+ return image_data
87
+
88
+
89
+ def main():
90
+ """Main function to run TSQA evaluation with plotting."""
91
+ if len(sys.argv) != 2:
92
+ print("Usage: python evaluate_tsqa_plot.py <model_name>")
93
+ print("Example: python evaluate_tsqa_plot.py openai-gpt-4o")
94
+ sys.exit(1)
95
+
96
+ model_name = sys.argv[1]
97
+
98
+ dataset_classes = [TSQADataset]
99
+ evaluation_functions = {
100
+ "TSQADataset": evaluate_tsqa,
101
+ }
102
+ evaluator = CommonEvaluatorPlot()
103
+ plot_functions = {
104
+ "TSQADataset": generate_time_series_plot,
105
+ }
106
+
107
+ results_df = evaluator.evaluate_multiple_models(
108
+ model_names=[model_name],
109
+ dataset_classes=dataset_classes,
110
+ evaluation_functions=evaluation_functions,
111
+ plot_functions=plot_functions,
112
+ max_samples=None, # Set to None for full evaluation
113
+ max_new_tokens=80,
114
+ )
115
+
116
+ print("\n" + "=" * 80)
117
+ print("FINAL RESULTS SUMMARY")
118
+ print("=" * 80)
119
+ print(results_df.to_string(index=False))
120
+ return results_df
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
code/OpenTSLM/evaluation/baseline/gruver_llmtime_tokenizer.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ import numpy as np
7
+ from functools import partial
8
+ from dataclasses import dataclass
9
+
10
+ # Please check the original code at https://github.com/ngruver/llmtime/blob/main/data/serialize.py
11
+ @dataclass
12
+ class SerializerSettings:
13
+ """
14
+ Settings for time series serialization and deserialization.
15
+ """
16
+ base: int = 10 # Numeric base for representation
17
+ prec: int = 3 # Number of digits after the 'decimal' point
18
+ signed: bool = True # Whether to include a sign for positive values
19
+ fixed_length: bool = False # If True, pad to a fixed number of digits
20
+ max_val: float = 1e7 # Maximum absolute value allowed
21
+ time_sep: str = ' ,' # Separator between time steps
22
+ bit_sep: str = ' ' # Separator between individual digits
23
+ plus_sign: str = '' # String to prepend for positive values
24
+ minus_sign: str = ' -' # String to prepend for negative values
25
+ half_bin_correction: bool = True # Apply half-bin correction on deserialization
26
+ decimal_point: str = '' # Literal to mark decimal point in serialized string
27
+ missing_str: str = ' Nan' # Representation for missing (NaN) values
28
+
29
+
30
+ def vec_num2repr(val: np.ndarray, base: int, prec: int, max_val: float):
31
+ """
32
+ Convert an array of floats into sign and digit representation in the given base.
33
+ """
34
+ base = float(base)
35
+ sign = np.where(val >= 0, 1, -1)
36
+ mag = np.abs(val)
37
+ max_bit_pos = int(np.ceil(np.log(max_val) / np.log(base)).item())
38
+
39
+ # Compute digits before the "decimal"
40
+ before = []
41
+ rem = mag.copy()
42
+ for i in range(max_bit_pos):
43
+ power = base ** (max_bit_pos - i - 1)
44
+ digit = (rem / power).astype(int)
45
+ before.append(digit)
46
+ rem = rem - digit * power
47
+ before = np.stack(before, axis=-1)
48
+
49
+ # Compute digits after the "decimal"
50
+ if prec > 0:
51
+ after = []
52
+ for i in range(prec):
53
+ power = base ** (-(i + 1))
54
+ digit = (rem / power).astype(int)
55
+ after.append(digit)
56
+ rem = rem - digit * power
57
+ after = np.stack(after, axis=-1)
58
+ digits = np.concatenate([before, after], axis=-1)
59
+ else:
60
+ digits = before
61
+
62
+ return sign, digits
63
+
64
+
65
+ def vec_repr2num(sign: np.ndarray, digits: np.ndarray, base: int, prec: int, half_bin_correction: bool = True) -> np.ndarray:
66
+ """
67
+ Convert sign and digit arrays back into floats.
68
+ """
69
+ base = float(base)
70
+ # Reverse the digit order for positional weights
71
+ flipped = np.flip(digits, axis=-1)
72
+ total_digits = digits.shape[1]
73
+ # Compute positional powers
74
+ powers = -np.arange(-prec, total_digits-prec)
75
+ mags = np.sum(flipped / (base ** powers), axis=-1)
76
+ if half_bin_correction:
77
+ mags = mags + (0.5 / (base ** prec))
78
+ return sign * mags
79
+
80
+
81
+ def serialize_arr(arr: np.ndarray, settings: SerializerSettings) -> str:
82
+ """
83
+ Serialize a 1D numpy array into a digit-level string using the given settings.
84
+ """
85
+ # Validate range
86
+ clean = np.where(np.isnan(arr), 0.0, arr)
87
+ assert np.all(np.abs(clean) <= settings.max_val), \
88
+ f"Values must be within ±{settings.max_val}"
89
+
90
+ # Convert numbers to sign and digits
91
+ to_repr = partial(vec_num2repr, base=settings.base, prec=settings.prec, max_val=settings.max_val)
92
+ sign_arr, digits_arr = to_repr(clean)
93
+ is_nan = np.isnan(arr)
94
+
95
+ def format_digits(digits):
96
+ # Optionally strip leading zeros
97
+ if not settings.fixed_length:
98
+ nz = np.where(digits != 0)[0]
99
+ if nz.size > 0:
100
+ digits = digits[nz[0]:]
101
+ else:
102
+ digits = np.array([0], dtype=int)
103
+ # Insert decimal point if specified
104
+ if settings.decimal_point and settings.prec > 0:
105
+ point_idx = len(digits) - settings.prec
106
+ digits = np.concatenate([digits[:point_idx], [-1], digits[point_idx:]])
107
+ # Join with bit separator
108
+ return settings.bit_sep.join(str(d) for d in digits if d != -1)
109
+
110
+ parts = []
111
+ for s, digs, missing in zip(sign_arr, digits_arr, is_nan):
112
+ if missing:
113
+ parts.append(settings.missing_str)
114
+ else:
115
+ sign_str = settings.plus_sign if s > 0 else settings.minus_sign
116
+ digit_str = format_digits(digs)
117
+ parts.append(sign_str + digit_str)
118
+
119
+ result = settings.time_sep.join(parts) + settings.time_sep
120
+ return result
121
+
122
+
123
+ def deserialize_str(bit_str: str, settings: SerializerSettings, ignore_last: bool = False, steps: int = None) -> np.ndarray:
124
+ """
125
+ Deserialize a serialized string back into a numpy array of floats.
126
+ """
127
+ tokens = [t for t in bit_str.split(settings.time_sep) if t]
128
+ if ignore_last:
129
+ tokens = tokens[:-1]
130
+ if steps is not None:
131
+ tokens = tokens[:steps]
132
+
133
+ sign_list = []
134
+ digit_list = []
135
+ for tok in tokens:
136
+ if tok == settings.missing_str.strip():
137
+ sign_list.append(1)
138
+ digit_list.append([0])
139
+ continue
140
+ # Determine sign
141
+ if settings.signed and tok.startswith(settings.minus_sign):
142
+ sign_list.append(-1)
143
+ tok = tok[len(settings.minus_sign):]
144
+ else:
145
+ sign_list.append(1)
146
+ if settings.signed and tok.startswith(settings.plus_sign):
147
+ tok = tok[len(settings.plus_sign):]
148
+ # Split digits
149
+ digs = [int(ch) for ch in tok.split(settings.bit_sep) if ch.isdigit()]
150
+ digit_list.append(digs)
151
+
152
+ # Pad to equal length
153
+ maxlen = max(len(d) for d in digit_list)
154
+ padded = np.array([([0]*(maxlen - len(d)) + d) for d in digit_list])
155
+
156
+ nums = vec_repr2num(
157
+ np.array(sign_list), padded,
158
+ base=settings.base, prec=settings.prec,
159
+ half_bin_correction=settings.half_bin_correction
160
+ )
161
+ return nums
162
+
163
+ gpt_settings = SerializerSettings(base=10, prec=3, signed=True, time_sep=', ', bit_sep=' ', minus_sign='-')
164
+ llama = SerializerSettings(base=10, prec=3, signed=True, half_bin_correction=True)
165
+
166
+ # Then wrap serialize_arr so it only takes the array
167
+ def gpt_formatter(arr: np.ndarray) -> str:
168
+ return serialize_arr(arr, gpt_settings)
169
+
170
+ def llama_formatter(arr: np.ndarray) -> str:
171
+ return serialize_arr(arr, llama)
172
+
173
+ # Backward compatibility: default to llama_formatter
174
+ # (or you can set to gpt_formatter if you prefer)
175
+ gruver_et_al_formatter = llama_formatter
code/OpenTSLM/evaluation/baseline/openai_pipeline.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Stanford University, ETH Zurich, and the project authors (see CONTRIBUTORS.md)
2
+ # SPDX-FileCopyrightText: 2025 This source file is part of the OpenTSLM open-source project.
3
+ #
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ import os
7
+ from openai import OpenAI
8
+ from typing import Optional
9
+
10
+ class OpenAIPipeline:
11
+ def __init__(self, model_name: str, api_key: str = None, temperature: float = 0.1, max_new_tokens: int = 1000):
12
+ """
13
+ A small wrapper around the new OpenAI v1 client for chat completions.
14
+ """
15
+ self.api_key = api_key or os.getenv("OPENAI_API_KEY")
16
+ if not self.api_key:
17
+ raise ValueError("API key must be provided via argument or OPENAI_API_KEY env var")
18
+ # instantiate the v1 client
19
+ self.client = OpenAI(api_key=self.api_key)
20
+ self.model_name = model_name
21
+ self.temperature = temperature
22
+
23
+ def __call__(self, prompt: str, max_new_tokens: int = 1000, return_full_text: bool = False, plot_data: Optional[str] = None):
24
+ """
25
+ Send a single-user-message chat completion request and return the generated text in HuggingFace pipeline format.
26
+ """
27
+
28
+ if plot_data:
29
+ content = [
30
+ {"type": "text", "text": prompt},
31
+ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{plot_data}", "detail": "high"}}
32
+ ]
33
+ else:
34
+ content = [{"type": "text", "text": prompt}]
35
+
36
+ resp = self.client.chat.completions.create(
37
+ model=self.model_name,
38
+ messages=[
39
+ {"role": "user", "content": content}
40
+ ],
41
+ temperature=self.temperature,
42
+ max_tokens=max_new_tokens,
43
+ )
44
+ # Return in HuggingFace pipeline format
45
+ return [{"generated_text": resp.choices[0].message.content.strip()}]
46
+