Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +18 -0
- RUN.md +49 -0
- code/OpenTSLM/.github/workflows/publish.yml +62 -0
- code/OpenTSLM/.github/workflows/static-analysis.yml +30 -0
- code/OpenTSLM/.gitignore +22 -0
- code/OpenTSLM/.linkspector.yml +12 -0
- code/OpenTSLM/.python-version +7 -0
- code/OpenTSLM/CITATION.cff +69 -0
- code/OpenTSLM/CONTRIBUTORS.md +17 -0
- code/OpenTSLM/LICENSE.md +9 -0
- code/OpenTSLM/LICENSES/MIT.txt +9 -0
- code/OpenTSLM/README.md +385 -0
- code/OpenTSLM/REUSE.toml +6 -0
- code/OpenTSLM/assets/ASL_Logo2.svg +44 -0
- code/OpenTSLM/assets/ASLwhite.svg +1 -0
- code/OpenTSLM/assets/CDHI_white.svg +0 -0
- code/OpenTSLM/assets/cdhi_logo.png +3 -0
- code/OpenTSLM/assets/ecg_rationale.png +3 -0
- code/OpenTSLM/assets/eth_cdhi_logo.png +3 -0
- code/OpenTSLM/assets/eth_logo.png +0 -0
- code/OpenTSLM/assets/har_rationale.png +3 -0
- code/OpenTSLM/assets/m4_caption.png +3 -0
- code/OpenTSLM/assets/schematic_overview_2.png +3 -0
- code/OpenTSLM/assets/schematic_overview_3.png +3 -0
- code/OpenTSLM/assets/sleep_rationale.png +3 -0
- code/OpenTSLM/assets/stanford_biodesign_logo.png +0 -0
- code/OpenTSLM/curriculum_learning.py +1732 -0
- code/OpenTSLM/data/.gitkeep +0 -0
- code/OpenTSLM/demo/huggingface/01_test_hf_tsqa.py +72 -0
- code/OpenTSLM/demo/huggingface/02_test_hf_m4.py +74 -0
- code/OpenTSLM/demo/huggingface/03_test_hf_har_cot.py +77 -0
- code/OpenTSLM/demo/huggingface/04_test_hf_sleep_cot.py +75 -0
- code/OpenTSLM/demo/huggingface/05_test_hf_ecg_qa_cot.py +79 -0
- code/OpenTSLM/evaluation/baseline/README.md +213 -0
- code/OpenTSLM/evaluation/baseline/common_evaluator.py +722 -0
- code/OpenTSLM/evaluation/baseline/common_evaluator_plot.py +459 -0
- code/OpenTSLM/evaluation/baseline/eval.sh +38 -0
- code/OpenTSLM/evaluation/baseline/eval_remaining.sh +18 -0
- code/OpenTSLM/evaluation/baseline/evaluate_all.py +92 -0
- code/OpenTSLM/evaluation/baseline/evaluate_ecg_qa.py +293 -0
- code/OpenTSLM/evaluation/baseline/evaluate_ecg_qa_plot.py +355 -0
- code/OpenTSLM/evaluation/baseline/evaluate_har.py +83 -0
- code/OpenTSLM/evaluation/baseline/evaluate_har_plot.py +113 -0
- code/OpenTSLM/evaluation/baseline/evaluate_pamap.py +75 -0
- code/OpenTSLM/evaluation/baseline/evaluate_sleep_cot.py +93 -0
- code/OpenTSLM/evaluation/baseline/evaluate_sleep_plot.py +120 -0
- code/OpenTSLM/evaluation/baseline/evaluate_tsqa.py +89 -0
- code/OpenTSLM/evaluation/baseline/evaluate_tsqa_plot.py +124 -0
- code/OpenTSLM/evaluation/baseline/gruver_llmtime_tokenizer.py +175 -0
- 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 |
+
[](https://pypi.org/project/opentslm)
|
| 10 |
+
[](https://doi.org/10.48550/arXiv.2510.02410)
|
| 11 |
+
[](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">
|
| 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
|
code/OpenTSLM/assets/ecg_rationale.png
ADDED
|
Git LFS Details
|
code/OpenTSLM/assets/eth_cdhi_logo.png
ADDED
|
Git LFS Details
|
code/OpenTSLM/assets/eth_logo.png
ADDED
|
code/OpenTSLM/assets/har_rationale.png
ADDED
|
Git LFS Details
|
code/OpenTSLM/assets/m4_caption.png
ADDED
|
Git LFS Details
|
code/OpenTSLM/assets/schematic_overview_2.png
ADDED
|
Git LFS Details
|
code/OpenTSLM/assets/schematic_overview_3.png
ADDED
|
Git LFS Details
|
code/OpenTSLM/assets/sleep_rationale.png
ADDED
|
Git LFS Details
|
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 |
+
|