GSD Evaluation Challenges#

The `gsd_evaluation`_ example demonstrates how to evaluate the performance of a GSD algorithm on a single datapoint and explains the individual performance metrics that are calculated.

With that you could set up a custom evaluation pipeline to run and then score the output of a GSD algorithm multiple datapoints and then aggregate the results. To make this process easier, we set up opinionated evaluation challenges that can be used to quickly perform the same evaluation with multiple algorithms and datasets.

Below, we will show how to use them on the example dataset.

Dataset#

To use the challenges, we need to dataset with reference information in the expected format. We will use the LabExampleDataset for this purpose.

from mobgap.data import LabExampleDataset

long_test = LabExampleDataset(reference_system="INDIP").get_subset(
    test="Test11"
)

Algorithm#

Next we need to create an instance of a valid GSD algorithm.

from mobgap.gait_sequences import GsdIluz

algo = GsdIluz()

This algorithm needs to be wrapped in a GsdEmulationPipeline to be used in the challenges. This pipeline takes care of extracting the correct data from the dataset and running the algorithm on it.

from mobgap.gait_sequences.pipeline import GsdEmulationPipeline

pipe = GsdEmulationPipeline(algo)

Let’s demonstrate that quickly on a single datapoint.

start end
gs_id
0 600 1201
1 2700 4201
2 4350 5251
3 7800 8851
4 9450 10201
5 10950 11551
6 13050 13651


Evaluation Challenge#

This pipeline can now be used as part of an evaluation challenge. An evaluation challenge takes care of two things: - Running the pipeline on multiple datapoints - Scoring the results per datapoint and then aggregating the results

We provide two challenges: - GsdEvaluation: This challenge simply runs the pipeline on all datapoints and then scores the results. - GsdEvaluationCV: This challenge runs a cross-validation on the dataset and then scores the results per fold.

Before we run the entire pipeline, let’s look at the scoring. We provide a default scoring function that calculates all the relevant performance metrics.

Let’s look at the code of it first.

from inspect import getsource

from mobgap.gait_sequences.evaluation import gsd_evaluation_scorer

print(getsource(gsd_evaluation_scorer))
def gsd_evaluation_scorer(pipeline: GsdEmulationPipeline, datapoint: BaseGaitDatasetWithReference) -> dict:
    """Evaluate the performance of a GSD algorithm on a single datapoint.

    This function is used to evaluate the performance of a GSD algorithm on a single datapoint.
    It calculates the performance metrics based on the detected gait sequences and the reference gait sequences.

    This is the default scoring functions for the GSD evaluation pipelines (``GsdEvaluation`` and ``GsdEvaluationCV``).

    Parameters
    ----------
    pipeline
        An instance of GSD emulation pipeline that wraps the algorithm that should be evaluated.
    datapoint
        The datapoint to be evaluated.

    Returns
    -------
    dict
        A dictionary containing the performance metrics.
        Note, that some results are wrapped in a ``NoAgg`` object or other aggregators.
        The results of this function are not expected to be parsed manually, but rather the function is expected to be
        used in the context of the :func:`~tpcp.validate.validate`/:func:`~tpcp.validate.cross_validate` functions or
        similar as scorer.
        This functions will aggregate the results and provide a summary of the performance metrics.

    """
    from mobgap.gait_sequences.evaluation import (
        calculate_matched_gsd_performance_metrics,
        calculate_unmatched_gsd_performance_metrics,
        categorize_intervals_per_sample,
    )

    # Run the algorithm on the datapoint
    detected_gs_list = pipeline.safe_run(datapoint).gs_list_
    reference_gs_list = datapoint.reference_parameters_.wb_list[["start", "end"]]
    n_overall_samples = len(datapoint.data_ss)
    sampling_rate_hz = datapoint.sampling_rate_hz

    matches = categorize_intervals_per_sample(
        gsd_list_detected=detected_gs_list, gsd_list_reference=reference_gs_list, n_overall_samples=n_overall_samples
    )

    # Calculate the performance metrics
    performance_metrics = {
        **calculate_unmatched_gsd_performance_metrics(
            gsd_list_detected=detected_gs_list,
            gsd_list_reference=reference_gs_list,
            sampling_rate_hz=sampling_rate_hz,
        ),
        **calculate_matched_gsd_performance_metrics(matches),
        "detected": NoAgg(detected_gs_list),
        "reference": NoAgg(reference_gs_list),
    }

    return performance_metrics

We can see that this method is relatively simple, using the gsd evaluation functions that we provide. So if you want to run your own scoring function, it should be straightforward to do so.

Note, the NoAgg wrapping some of the return values. This is a special aggregator that tells the challenge to not try to aggregate the respective values. For all other values, the challenge will try average the values across all datapoints.

To learn more about these special aggregators, check out the tpcp example.

The scoring function takes care of running the pipeline. So we can test the scorer, by just providing it with a pipeline and a datapoint.

Note, that we remove the “NoAgg” parameters fromt he results, as they don’t visualize well.

{'accuracy': 0.7349292709466811,
 'detected_gs_duration_s': 60.14,
 'detected_num_gs': 7,
 'f1_score': 0.6371400198609732,
 'fn_samples': 839,
 'fp_samples': 2815,
 'gs_absolute_duration_error_s': 19.700000000000003,
 'gs_absolute_relative_duration_error': 0.487141444114738,
 'gs_absolute_relative_duration_error_log': 0.3968557834081124,
 'gs_duration_error_s': 19.700000000000003,
 'gs_relative_duration_error': 0.487141444114738,
 'npv': 0.8919093017263592,
 'num_gs_absolute_error': 1,
 'num_gs_absolute_relative_error': 0.16666666666666666,
 'num_gs_absolute_relative_error_log': 0.15415067982725836,
 'num_gs_error': 1,
 'num_gs_relative_error': 0.16666666666666666,
 'precision': 0.5326249377386685,
 'recall': 0.7926859402026192,
 'reference_gs_duration_s': 40.44,
 'reference_num_gs': 6,
 'specificity': 0.7109262682275621,
 'tn_samples': 6923,
 'tp_samples': 3208}

The challenge will call this scoring method for each datapoint in the dataset. Let’s test this with the GsdEvaluation challenge.

from mobgap.gait_sequences.evaluation import GsdEvaluation

eval_challenge = GsdEvaluation(long_test, scoring=gsd_evaluation_scorer)

We can now run the challenge.

eval_challenge = eval_challenge.run(pipe)
Datapoints:   0%|          | 0/3 [00:00<?, ?it/s]
Datapoints:  33%|███▎      | 1/3 [00:00<00:00,  2.38it/s]
Datapoints:  67%|██████▋   | 2/3 [00:00<00:00,  2.29it/s]/home/docs/checkouts/readthedocs.org/user_builds/mobgap/checkouts/v0.5.0/mobgap/data/_mobilised_matlab_loader.py:1050: UserWarning: There were multiple ICs with the same index value, but different LR labels. This is likely an issue with the reference system you should further investigate. For now, we set the `lr_label` of the stride corresponding to this IC to Nan. However, both values still remain in the IC list.
  return parse_reference_parameters(

Datapoints: 100%|██████████| 3/3 [00:01<00:00,  2.18it/s]
Datapoints: 100%|██████████| 3/3 [00:01<00:00,  2.22it/s]

The results are stored in the results_ attribute and contain the aggregated and the raw results per datapoint. To learn more about the results, check the validate documentation.

The aggregated results across all datapoints are available in all columns not starting with agg__

agg__reference_gs_duration_s agg__detected_gs_duration_s agg__gs_duration_error_s agg__gs_relative_duration_error agg__gs_absolute_duration_error_s agg__gs_absolute_relative_duration_error agg__gs_absolute_relative_duration_error_log agg__detected_num_gs agg__reference_num_gs agg__num_gs_error agg__num_gs_relative_error agg__num_gs_absolute_error agg__num_gs_absolute_relative_error agg__num_gs_absolute_relative_error_log agg__tp_samples agg__fp_samples agg__fn_samples agg__precision agg__recall agg__f1_score agg__tn_samples agg__specificity agg__accuracy agg__npv
0 48.926667 58.62 9.693333 0.237191 9.693333 0.237191 0.200297 6.0 5.0 1.0 0.333333 1.666667 0.444444 0.333816 3730.333333 2138.666667 1166.0 0.632099 0.766355 0.688517 10477.333333 0.815958 0.802683 0.899915


The raw results are stored in the columns starting with single__.

And it is often helpful to explode them to get a better overview.

exploded_results = (
    single_results.explode(single_results.columns.to_list())
    .rename_axis("fold")
    .set_index(
        pd.MultiIndex.from_tuples(
            (dl := validate_results["data_labels"].explode().to_list()),
            names=list(dl[0]._fields),
        ),
        append=True,
    )
)
exploded_results.columns = exploded_results.columns.str.removeprefix("single__")
exploded_results
reference_gs_duration_s detected_gs_duration_s gs_duration_error_s gs_relative_duration_error gs_absolute_duration_error_s gs_absolute_relative_duration_error gs_absolute_relative_duration_error_log detected_num_gs reference_num_gs num_gs_error num_gs_relative_error num_gs_absolute_error num_gs_absolute_relative_error num_gs_absolute_relative_error_log tp_samples fp_samples fn_samples precision recall f1_score tn_samples specificity accuracy npv detected reference
fold cohort participant_id time_measure test trial
0 HA 001 TimeMeasure1 Test11 Trial1 40.44 60.14 19.7 0.487141 19.7 0.487141 0.396856 7 6 1 0.166667 1 0.166667 0.154151 3208 2815 839 0.532625 0.792686 0.63714 6923 0.710926 0.734929 0.891909 start end gs_id 0 ... start end wb_id 0 ...
002 TimeMeasure1 Test11 Trial1 40.82 49.62 8.8 0.215581 8.8 0.215581 0.195222 6 3 3 1.0 3 1.0 0.693147 3132 1835 955 0.630562 0.766332 0.691849 10080 0.845992 0.825647 0.913457 start end gs_id 0 ... start end wb_id 0 ...
MS 001 TimeMeasure1 Test11 Trial1 65.52 66.1 0.58 0.008852 0.58 0.008852 0.008813 5 6 -1 -0.166667 1 0.166667 0.154151 4851 1766 1704 0.733112 0.740046 0.736562 14429 0.890954 0.847473 0.894378 start end gs_id 0 ... start end wb_id 0 ...


The detected and reference columns in this dataframe contain the raw un-aggregated gait-sequences. So if we want to perform further evaluation on them (e.g. visualize them), we can use them.

raw_gs_list = pd.concat(
    exploded_results.loc[:, ["detected", "reference"]].stack().to_dict(),
    names=[*exploded_results.index.names, "system"],
).unstack("system")
raw_gs_list
start end
system detected reference detected reference
fold cohort participant_id time_measure test trial
0 HA 001 TimeMeasure1 Test11 Trial1 0 600.0 632.0 1201.0 988.0
1 2700.0 2864.0 4201.0 3325.0
2 4350.0 3853.0 5251.0 5085.0
3 7800.0 7641.0 8851.0 8621.0
4 9450.0 9451.0 10201.0 9932.0
5 10950.0 11989.0 11551.0 12517.0
6 13050.0 NaN 13651.0 NaN
002 TimeMeasure1 Test11 Trial1 0 450.0 485.0 1201.0 1131.0
1 2400.0 1746.0 3301.0 3554.0
2 3450.0 6083.0 4051.0 7708.0
3 5700.0 NaN 7201.0 NaN
4 7350.0 NaN 7951.0 NaN
5 15000.0 NaN 15601.0 NaN
MS 001 TimeMeasure1 Test11 Trial1 0 750.0 1019.0 1651.0 1768.0
1 4650.0 4534.0 6151.0 5549.0
2 12900.0 9665.0 14851.0 10569.0
3 20100.0 12337.0 21151.0 14633.0
4 21300.0 20151.0 22501.0 20982.0
5 NaN 21378.0 NaN 22129.0


Further there are some runtime information available (i.e. when the challenge was started, and how long it took).

('2024-07-16T10:54:55.377922+00:00', '2024-07-16T10:54:56.832988+00:00')
1.4549340889998348

Using GsdEvaluation is great, if you are only comparing (or planning to compare) non-ML algorithms, or algorithms that don’t require further optimization (e.g. through GridSearch).

Therefore, it is generally recommended to run a cross-validation with GsdEvaluationCV. This allows you to evaluate the performance of the algorithm on multiple folds of the dataset and through the use of DummyOptimize you can also use algorithms without optimization in the same pipeline for comparison.

Let’s demonstrate the use of GsdEvaluationCV on the example dataset using the same algorithm once with and once without GridSearch.

For the CV-based challenge, we need to set up a cross-validation. As we only have 3 datapoints here, we will use a 3-fold cross-validation without grouping or stratification. In a real-world scenario, you would use a more sophisticated cross-validation strategy. You can learn more about cross-validation in the tpcp example.

Further, to speed things up, we are going to use multi-processing. We can configure this using the n_jobs parameter that we pass to the internal cross_validate function via the cv_params parameters

from mobgap.gait_sequences.evaluation import GsdEvaluationCV

eval_challenge_cv = GsdEvaluationCV(
    long_test,
    cv_iterator=3,
    scoring=gsd_evaluation_scorer,
    cv_params={"n_jobs": 2, "return_optimizer": True},
)

To use our pipeline from above, we need to wrap it in a DummyOptimize instance. This will basically skip any optimization on the train set and just apply the pipeline to the test set.

from tpcp.optimize import DummyOptimize

eval_challenge_cv = eval_challenge_cv.run(
    DummyOptimize(pipe, ignore_potential_user_error_warning=True)
)
CV Folds:   0%|          | 0/3 [00:00<?, ?it/s]
CV Folds:  33%|███▎      | 1/3 [00:10<00:20, 10.21s/it]
CV Folds:  67%|██████▋   | 2/3 [00:10<00:04,  4.55s/it]
CV Folds: 100%|██████████| 3/3 [00:11<00:00,  2.68s/it]
CV Folds: 100%|██████████| 3/3 [00:11<00:00,  3.75s/it]

The results now are a little bit more complex, as they contain the results for each fold. In addition, we have information for the train and the test set. The test set results, are what we are usually looking for. The train set results, are only calculated when providing the return_train_score parameter to the cv_params.

As before our main results are the aggregated results.

test__agg__reference_gs_duration_s test__agg__detected_gs_duration_s test__agg__gs_duration_error_s test__agg__gs_relative_duration_error test__agg__gs_absolute_duration_error_s test__agg__gs_absolute_relative_duration_error test__agg__gs_absolute_relative_duration_error_log test__agg__detected_num_gs test__agg__reference_num_gs test__agg__num_gs_error test__agg__num_gs_relative_error test__agg__num_gs_absolute_error test__agg__num_gs_absolute_relative_error test__agg__num_gs_absolute_relative_error_log test__agg__tp_samples test__agg__fp_samples test__agg__fn_samples test__agg__precision test__agg__recall test__agg__f1_score test__agg__tn_samples test__agg__specificity test__agg__accuracy test__agg__npv
0 40.44 60.14 19.70 0.487141 19.70 0.487141 0.396856 7.0 6.0 1.0 0.166667 1.0 0.166667 0.154151 3208.0 2815.0 839.0 0.532625 0.792686 0.637140 6923.0 0.710926 0.734929 0.891909
1 40.82 49.62 8.80 0.215581 8.80 0.215581 0.195222 6.0 3.0 3.0 1.000000 3.0 1.000000 0.693147 3132.0 1835.0 955.0 0.630562 0.766332 0.691849 10080.0 0.845992 0.825647 0.913457
2 65.52 66.10 0.58 0.008852 0.58 0.008852 0.008813 5.0 6.0 -1.0 -0.166667 1.0 0.166667 0.154151 4851.0 1766.0 1704.0 0.733112 0.740046 0.736562 14429.0 0.890954 0.847473 0.894378


The raw results are stored in the columns starting with single__.

single_results = cv_results.filter(like="test__single__")
exploded_results_cv = (
    single_results.explode(single_results.columns.to_list())
    .rename_axis("fold")
    .set_index(
        pd.MultiIndex.from_tuples(
            (dl := cv_results["test__data_labels"].explode().to_list()),
            names=list(dl[0]._fields),
        ),
        append=True,
    )
)
exploded_results_cv.columns = exploded_results_cv.columns.str.removeprefix(
    "test__single__"
)
exploded_results_cv
reference_gs_duration_s detected_gs_duration_s gs_duration_error_s gs_relative_duration_error gs_absolute_duration_error_s gs_absolute_relative_duration_error gs_absolute_relative_duration_error_log detected_num_gs reference_num_gs num_gs_error num_gs_relative_error num_gs_absolute_error num_gs_absolute_relative_error num_gs_absolute_relative_error_log tp_samples fp_samples fn_samples precision recall f1_score tn_samples specificity accuracy npv detected reference
fold cohort participant_id time_measure test trial
0 HA 001 TimeMeasure1 Test11 Trial1 40.44 60.14 19.7 0.487141 19.7 0.487141 0.396856 7 6 1 0.166667 1 0.166667 0.154151 3208 2815 839 0.532625 0.792686 0.63714 6923 0.710926 0.734929 0.891909 start end gs_id 0 ... start end wb_id 0 ...
1 HA 002 TimeMeasure1 Test11 Trial1 40.82 49.62 8.8 0.215581 8.8 0.215581 0.195222 6 3 3 1.0 3 1.0 0.693147 3132 1835 955 0.630562 0.766332 0.691849 10080 0.845992 0.825647 0.913457 start end gs_id 0 ... start end wb_id 0 ...
2 MS 001 TimeMeasure1 Test11 Trial1 65.52 66.1 0.58 0.008852 0.58 0.008852 0.008813 5 6 -1 -0.166667 1 0.166667 0.154151 4851 1766 1704 0.733112 0.740046 0.736562 14429 0.890954 0.847473 0.894378 start end gs_id 0 ... start end wb_id 0 ...


And the raw outputs:

raw_gs_list_cv = pd.concat(
    exploded_results_cv.loc[:, ["detected", "reference"]].stack().to_dict(),
    names=[*exploded_results_cv.index.names, "system"],
).unstack("system")
raw_gs_list_cv
start end
system detected reference detected reference
fold cohort participant_id time_measure test trial
0 HA 001 TimeMeasure1 Test11 Trial1 0 600.0 632.0 1201.0 988.0
1 2700.0 2864.0 4201.0 3325.0
2 4350.0 3853.0 5251.0 5085.0
3 7800.0 7641.0 8851.0 8621.0
4 9450.0 9451.0 10201.0 9932.0
5 10950.0 11989.0 11551.0 12517.0
6 13050.0 NaN 13651.0 NaN
1 HA 002 TimeMeasure1 Test11 Trial1 0 450.0 485.0 1201.0 1131.0
1 2400.0 1746.0 3301.0 3554.0
2 3450.0 6083.0 4051.0 7708.0
3 5700.0 NaN 7201.0 NaN
4 7350.0 NaN 7951.0 NaN
5 15000.0 NaN 15601.0 NaN
2 MS 001 TimeMeasure1 Test11 Trial1 0 750.0 1019.0 1651.0 1768.0
1 4650.0 4534.0 6151.0 5549.0
2 12900.0 9665.0 14851.0 10569.0
3 20100.0 12337.0 21151.0 14633.0
4 21300.0 20151.0 22501.0 20982.0
5 NaN 21378.0 NaN 22129.0


If we compare these results to the ones from the non-CV challenge, we can see that “single” results are identical, just that they were called in multiple folds. This is expected, as we used DummyOptimize and thus didn’t optimize the algorithm.

Let’s try a tpcp.optimize.GridSearch on the algorithm to see how the results change. For the gridsearch, we will re-use the same scoring function as before, but we need to specify, which scoring result we want to optimize for.

from sklearn.model_selection import ParameterGrid
from tpcp.optimize import GridSearch

para_grid = ParameterGrid({"algo__window_length_s": [2, 3, 4]})
optimizer = GridSearch(pipe, para_grid, return_optimized="precision")

The optimizer can now be used in the same CV challenge as before. This way we can guarantee that the same folds are used for the optimization and the evaluation and ensure the best possible comparison between the algorithms versions.

eval_challenge_cv = eval_challenge_cv.clone().run(optimizer)
CV Folds:   0%|          | 0/3 [00:00<?, ?it/s]
CV Folds:  33%|███▎      | 1/3 [00:06<00:13,  6.77s/it]
CV Folds:  67%|██████▋   | 2/3 [00:07<00:02,  2.96s/it]
CV Folds: 100%|██████████| 3/3 [00:10<00:00,  3.09s/it]
CV Folds: 100%|██████████| 3/3 [00:10<00:00,  3.43s/it]

The results we are seeing now are generated by the internally optimized version of the algorithm.

Because we used cv_params={"return_optimizer": True} we can also access the optimizer per fold directly. This can be useful to get more insights into the optimization process and what the optimal parameters were.

0    GridSearch(n_jobs=None, parameter_grid=<sklear...
1    GridSearch(n_jobs=None, parameter_grid=<sklear...
2    GridSearch(n_jobs=None, parameter_grid=<sklear...
Name: optimizer, dtype: object

We can get the best parameters per fold by directly interacting with the optimizer instances.

best_params = opt_results.apply(lambda x: pd.Series(x.best_params_))
best_params
algo__window_length_s
0 3
1 2
2 2


Or we can go much deeper, by getting all information about the optimization process. Let’s look at what went on in fold 0

With that, we hope it becomes clear, how these challenges can be extremely valuable, when benchmarking algorithms across datasets. To see how we evaluate the performance of the algorithms available in mobgap, check out the other gsd evaluation examples.

Total running time of the script: (0 minutes 30.124 seconds)

Estimated memory usage: 9 MB

Gallery generated by Sphinx-Gallery