Skip to content

Commit a89ba06

Browse files
authored
Extend with SVM probes (#12)
* Implement new probe polynomial kernel SVM. This commit implements a new probe type, the polynomial kernel SVM. The SVM allows for linear probing as well as non-linear probing with complete control over the nonlinearity of the probe. The intention of this probe is as an alternative to the single-layer perceptron, as well as analyzes into how much nonlinearity is needed for a specific downstream task. The SVM is trained using Bayesian hyperparameter search, using Scikit-Optimize for the optimization. Expect longer run times compared to the perceptron. This update also includes refactoring of the existing code to make additional probe types simpler to implement. New with this update is also the possibility to store trained models. Use the `store_models` argument in the config file to toggle model storing. In case of `store_models=True`, the entire dataset is used for training the probe, no splitting into training and validation sets. The models are stored with the results together with a self-contained run script to provide easy loading. * Update github workflow Github actions now only run on the original repository and not on forks * Remove unnecessary files * Update documentation * Add config checks. Merged linear_probe.py into evaluation.py. Renamed everything relating to storing the trained probe from using model to usingprobe. Bug fixes. * Bug fix linear and svm. * Bug fix svm * Add coef0 to optimizeable parameters for SVM. * Align svm probe saving with linear model * Add hyperparameter tuning of epsilon in support vector regression * Update documentation and citation
1 parent 02ce50c commit a89ba06

18 files changed

Lines changed: 1094 additions & 350 deletions

.github/workflows/docs.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ concurrency:
1717
jobs:
1818
build:
1919
runs-on: ubuntu-latest
20+
# Do not try to build documentation from forks
21+
if: github.event.repository.fork == false
2022
steps:
2123
- uses: actions/checkout@v4
2224

@@ -40,6 +42,8 @@ jobs:
4042
deploy:
4143
needs: build
4244
runs-on: ubuntu-latest
45+
# Do not try to deploy documentation from forks
46+
if: github.event.repository.fork == false
4347
environment:
4448
name: github-pages
4549
url: ${{ steps.deployment.outputs.page_url }}

CITATION.cff

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ authors:
1313
- family-names: Schneider
1414
given-names: Jannik
1515
title: "NeuCo-Bench"
16-
version: 1.0
16+
version: 1.1
1717
date-released: 2025-05-12
18-
url: https://github.com/embed2scale/benchmark
18+
url: https://github.com/embed2scale/NeuCo-Bench

README.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[![Docs](https://img.shields.io/badge/docs-MkDocs-526CFE?logo=materialformkdocs&logoColor=fff)](https://embed2scale.github.io/NeuCo-Bench/)
44
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
55

6-
**TL;DR**: *Originally developed to evaluate challenge submissions for the 2025 EARTHVISION Challenge at CVPR ([competition details](https://www.grss-ieee.org/events/earthvision-2025/?tab=challenge)), NeuCo-Bench is now released for local benchmarking and evaluation - additional tech details in [http://arxiv.org/html/2510.17914](http://arxiv.org/html/2510.17914).*
6+
**TL;DR**: *Originally developed to evaluate challenge submissions for the 2025 EARTHVISION Challenge at CVPR ([competition details](https://www.grss-ieee.org/events/earthvision-2025/?tab=challenge)), NeuCo-Bench is now released for local benchmarking and evaluation - additional tech details in [the NeuCo-Bench paper](https://openaccess.thecvf.com/content/CVPR2026W/EarthVision/html/Vinge_NeuCo-Bench_A_Novel_Benchmark_Framework_for_Neural_Embeddings_in_Earth_CVPRW_2026_paper.html).*
77

88
---
99

@@ -156,13 +156,12 @@ For details on how to contribute, please see [CONTRIBUTING.md](.github/CONTRIBUT
156156
## How to cite
157157

158158
```BibTeX
159-
@article{Vinge2025NeuCoBench,
160-
author = {Rikard Vinge and Isabelle Wittmann and Jannik Schneider and Michael Marszalek and Luis Gilch and Thomas Brunschwiler and Conrad M Albrecht},
161-
title = {NeuCo-Bench: A Novel Benchmark Framework for Neural Embeddings in Earth Observation},
162-
journal = {arXiv preprint arXiv:2510.17914},
163-
year = {2025},
164-
url = {https://arxiv.org/abs/2510.17914},
165-
doi = {10.48550/arXiv.2510.17914},
166-
note = {Submitted on 19 Oct 2025},
159+
@InProceedings{Vinge_2026_CVPR,
160+
author = {Vinge, Rikard and Wittmann, Isabelle and Schneider, Jannik and Marszalek, Michael and Gilch, Luis and Brunschwiler, Thomas and Albrecht, Conrad M},
161+
title = {NeuCo-Bench: A Novel Benchmark Framework for Neural Embeddings in Earth Observation},
162+
booktitle = {Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition (CVPR) Workshops},
163+
month = {June},
164+
year = {2026},
165+
pages = {8063-8074}
167166
}
168167
```

benchmark/data/embeddings.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import json
22
import logging
3-
import re
4-
import ast
53
from pathlib import Path
6-
from typing import Optional, Set
4+
from typing import Optional, Set, Tuple
75

86
import numpy as np
97
import pandas as pd
@@ -40,9 +38,9 @@ def load_submission(
4038
file_path: Path,
4139
valid_ids: Set[str],
4240
expected_dim: int | None = None,
43-
exclude_file: Optional[Path] = None,
41+
exclude_file: Path | None = None,
4442
standardize: bool = True,
45-
) -> (pd.DataFrame, int):
43+
) -> Tuple[pd.DataFrame, int]:
4644
"""
4745
Load and preprocess CSV of embeddings.
4846

benchmark/data/labels.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def get_annotations(folder_path: Union[str, Path]) -> pd.DataFrame:
2727

2828
for csv_path in folder.glob("*.csv"):
2929
task_name, task_type = csv_path.stem.split("__", 1)
30+
prev_len = len(entries)
3031
with csv_path.open(newline='') as csvfile:
3132
reader = csv.DictReader(csvfile)
3233
for row in reader:
@@ -40,7 +41,7 @@ def get_annotations(folder_path: Union[str, Path]) -> pd.DataFrame:
4041
'task_name': task_name,
4142
'task_type': task_type,
4243
})
43-
logger.info("Processed %s: %d valid entries", csv_path.name, len(entries))
44+
logger.info("Processed %s: %d valid entries (%d total)", csv_path.name, len(entries) - prev_len, len(entries))
4445

4546
if sorted_out:
4647
logger.warning("Skipped %d rows due to missing labels", sorted_out)

benchmark/evaluation/config.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
2+
from evaluation.probes.linear import validate_config as linear_validate_config
3+
from evaluation.probes.svm import validate_config as svm_validate_config
4+
5+
def check_config(config: dict):
6+
"""Validates that the supplied configuration file is ok.
7+
8+
Args:
9+
Config (dict): Configuration dictionary
10+
"""
11+
12+
probe_info = {'linear': {'val_fun': linear_validate_config},
13+
'svm': {'val_fun': svm_validate_config}
14+
}
15+
16+
# Base parameters
17+
for param in ['k_folds', 'probe_type', 'probe_params']:
18+
assert param in config.keys(), f"Parameter `{param}` missing from config."
19+
20+
# Optional parameters
21+
if 'embedding_dim' in config.keys():
22+
assert (config['embedding_dim'] is None) or (isinstance(config['embedding_dim'], int)), f"`embedding_dim` must be integer or None but got {type(config['embedding_dim'])}."
23+
24+
bool_params = ['standardize_embeddings', 'normalize_labels', 'enable_plots', 'update_leaderboard', 'output_fold_results', 'store_probes']
25+
for p in bool_params:
26+
if p in config.keys():
27+
assert (isinstance(config[p], bool)), f"`{p}` must be boolean but got {type(config[p])}."
28+
29+
if 'task_filter' in config.keys():
30+
assert (config['task_filter'] is None) or (isinstance(config['task_filter'], list)), f"`task_filter` must be list or None but got {type(config['task_filter'])}."
31+
if config['task_filter'] is not None:
32+
for t in config['task_filter']:
33+
assert isinstance(t, str), f"`task_filter elements must be strings but got {type(t)} for task {t} (task_filter: {config['task_filter']})."
34+
35+
# Probe parameters
36+
if config['probe_type'] in probe_info.keys():
37+
probe_info[config['probe_type']]['val_fun'](config['probe_params'])
38+
else:
39+
raise NotImplementedError(f"probe_type `{config['probe_type']}` is not implemented. Currently implemented probes are: {list(probe_info.keys())}")
40+
41+
42+
43+

benchmark/evaluation/evaluation.py

Lines changed: 153 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
1-
import argparse
21
import json
32
import logging
3+
import os
44
from datetime import datetime
55
from pathlib import Path
66
import torch
7+
import numpy as np
8+
import pandas as pd
9+
from typing import Optional
10+
from sklearn.model_selection import ShuffleSplit
711

812
from data.embeddings import load_submission
913
from data.labels import get_annotations
10-
from evaluation.linear_probing import cross_validate
11-
from evaluation.results import save_results, summarize_runs
12-
from evaluation.utils import fix_all_seeds
14+
from evaluation.probes.linear import LinearFoldRunner
15+
from evaluation.probes.svm import SVMHPOptimizer
16+
from evaluation.results import save_results
17+
from evaluation.utils import fix_all_seeds, TaskResult
18+
from evaluation.visualisations import save_loss_curve
19+
1320

1421
# Logging setup
1522
logging.basicConfig(level=logging.INFO)
1623
logger = logging.getLogger("benchmarking")
1724

25+
1826
def evaluate(submission_file: Path,
1927
annotation_path: Path,
2028
method_name: str,
@@ -35,14 +43,6 @@ def evaluate(submission_file: Path,
3543
exclude_file: Path to file containing embedding IDs to exclude. If not provided, exclude no embeddings.
3644
"""
3745
fix_all_seeds(seed=42)
38-
device = torch.device(config.get("device", "cuda" if torch.cuda.is_available() else "cpu"))
39-
40-
# Determine device
41-
device = (
42-
torch.device("cuda" if torch.cuda.is_available() else "cpu")
43-
if config.get("device", "auto") == "auto"
44-
else torch.device(config["device"])
45-
)
4646

4747
# Load data
4848
annotation_df = get_annotations(annotation_path)
@@ -82,16 +82,15 @@ def evaluate(submission_file: Path,
8282
df=group,
8383
task_type=task_type,
8484
task_name=task_name,
85-
device=device,
86-
batch_size=config["batch_size"],
85+
probe_type=config["probe_type"],
86+
probe_params=config["probe_params"],
8787
n_splits=config["k_folds"],
88-
epochs=config["epochs"],
8988
embedding_dim=embedding_dim,
90-
learning_rate=config["learning_rate"],
9189
output_dir=run_dir,
9290
filename_prefix=submission_file.stem,
9391
enable_plots=config.get("enable_plots", True),
9492
output_fold_results=config.get("output_fold_results", False),
93+
store_probes=config.get('store_probes', False),
9594
)
9695

9796
task_q_scores[task_name] = result.q_statistic
@@ -100,6 +99,143 @@ def evaluate(submission_file: Path,
10099
save_results(experiment_name=experiment_name,
101100
task_q_scores=task_q_scores,
102101
task_acc_scores=task_acc_scores,
103-
output_dir=run_dir, config=config)
102+
output_dir=run_dir,
103+
config=config,
104+
)
104105

105106
logger.info("Finished evaluation.")
107+
108+
109+
def cross_validate(
110+
df: pd.DataFrame,
111+
task_type: str,
112+
task_name: str,
113+
probe_type: str,
114+
probe_params: dict,
115+
n_splits: int,
116+
embedding_dim: int,
117+
output_dir: Path,
118+
filename_prefix: str,
119+
enable_plots: bool,
120+
random_seed: int = 42,
121+
output_fold_results: Optional[bool] = False,
122+
store_probes: Optional[bool] = False,
123+
) -> TaskResult:
124+
"""Perform shuffle split validation with a linear probe.
125+
Returns the trained model from the last split in the return object.
126+
127+
Args:
128+
df (pandas.DataFrame): Dataframe to evaluate.
129+
task_type (str): Type of task, either "classification" or "regression".
130+
task_name (str): Name of task.
131+
probe_type (str): String with the probe type. Currently implemented are 'linear' and 'svm', the former using a single linear layer and the second an SVM.
132+
probe_params (dict): Dictionary with parameters for probe.
133+
device (torch.device): Device, CPU or GPU, to run training and inference.
134+
n_splits (int): Number of repetitions of Linear Probe evaluation. Use to gather statistics.
135+
embedding_dim (int): Size of embeddings.
136+
output_dir (str): Path to folder to store results in.
137+
enable_plots (bool): Toggle storing of plots. Set to True to store plots.
138+
random_seed (int): Integer seed for random number generator.
139+
output_fold_results (bool, optional): Toggle storing performance metric per fold in addition to summary statistics. Default is False, in which case performance per fold is not stored.
140+
store_probes (bool, optional): Toggle storing model under <output_dir>/models/<task_name>. Default is False. The model from the final fold is stored.
141+
Returns:
142+
TaskResult: A TaskResult instance containing the evaluation results.
143+
"""
144+
145+
logger.info("Cross-validation start: %s", task_name)
146+
147+
assert probe_type in ['linear', 'svm'], f'Probe type must be "svm" or "linear", but was {probe_type}.'
148+
assert output_dir is not None, f'output_dir cannot be empty.'
149+
150+
if probe_params is None:
151+
probe_params = {}
152+
153+
splitter = ShuffleSplit(
154+
n_splits=n_splits, test_size=0.1, random_state=random_seed
155+
)
156+
loss_curve_x_label = None
157+
if probe_type == 'linear':
158+
loss_curve_x_label = "Epoch"
159+
160+
# Determine device
161+
device = probe_params.pop("device", "auto")
162+
device = (
163+
torch.device("cuda" if torch.cuda.is_available() else "cpu")
164+
if device == "auto"
165+
else torch.device(device)
166+
)
167+
168+
trainer = LinearFoldRunner(df=df,
169+
task_type=task_type,
170+
task_name=task_name,
171+
device=device,
172+
output_dir=output_dir,
173+
filename_prefix=filename_prefix,
174+
enable_plots=enable_plots,
175+
embedding_dim=embedding_dim,
176+
probe_params=probe_params,
177+
splitter=splitter,
178+
store_probes=store_probes,
179+
)
180+
181+
fold_results = trainer.train()
182+
model = trainer.model
183+
best_params = trainer.hyperparams
184+
185+
elif probe_type == 'svm':
186+
loss_curve_x_label = "Hyperparameter setting"
187+
188+
# Set random seed for optimizer if not already defined
189+
opt_params = probe_params.get('opt_params', None)
190+
if opt_params is not None:
191+
if 'random_state' not in opt_params:
192+
opt_params['random_state'] = random_seed
193+
probe_params['opt_params'] = opt_params
194+
195+
trainer = SVMHPOptimizer(
196+
df=df,
197+
task_type=task_type,
198+
task_name=task_name,
199+
output_dir=output_dir,
200+
filename_prefix=filename_prefix,
201+
enable_plots=enable_plots,
202+
splitter=splitter,
203+
**probe_params,
204+
)
205+
fold_results = trainer.train()
206+
model = trainer.model
207+
best_params = trainer.best_model_state
208+
209+
# Aggregate and save results
210+
train_losses = [fr.train_loss for fr in fold_results]
211+
val_losses = [fr.val_loss for fr in fold_results]
212+
best_metrics = np.array([fr.best_metric for fr in fold_results], dtype=np.float64)
213+
214+
save_loss_curve(train_losses, output_dir, task_name, loss_type="train", xlabel=loss_curve_x_label,)
215+
save_loss_curve(val_losses, output_dir, task_name, loss_type="validation", xlabel=loss_curve_x_label,)
216+
217+
# Compute summary Q-statistic
218+
mean_score = np.nanmean(best_metrics)
219+
std_dev = np.nanstd(best_metrics)
220+
q_stat = mean_score / (0.02 + std_dev) * 2
221+
222+
result_metrics = {
223+
"q_stat": q_stat,
224+
"mean_score": mean_score,
225+
"std_dev": std_dev,
226+
"hyperparamaters": best_params,
227+
}
228+
if output_fold_results:
229+
result_metrics["q_t"] = best_metrics.tolist()
230+
231+
(output_dir / task_name / f"{task_name}_result.json").write_text(
232+
json.dumps(result_metrics, indent=2)
233+
)
234+
235+
# Store models
236+
if store_probes:
237+
model_save_path = output_dir / task_name / 'probe'
238+
os.makedirs(model_save_path, exist_ok=True)
239+
trainer.save_model(model_save_path)
240+
241+
return TaskResult(task_name, q_stat, mean_score, std_dev, model)

0 commit comments

Comments
 (0)