Skip to content

Commit 2625d2e

Browse files
warreveysclaude
andauthored
Graded tasks integrations (#53)
* linting * first relevancy task (house) integration * Tech graded relevancy * Last challenge files * Fix KeyError when requested metrics omit the default key metric _record_dataset_result logged the per-dataset "key metric" via metrics_dict[task.default_metrics[0]]. When a caller passes a custom `metrics` set that excludes the task's first default metric (e.g. ["ndcg@100"] for ranking tasks whose default_metrics[0] is "ndcg"), that lookup raised KeyError and aborted the whole evaluation. Fall back to the first computed metric, and skip the log line if none exist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test tasks --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1bb0440 commit 2625d2e

11 files changed

Lines changed: 483 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22

33
### Feat
44

5+
- ``HouseGradedSkillExtractRanking``: graded-relevance skill-extraction ranking
6+
task on the HOUSE subset of CAREER, re-annotated against the full ESCO v1.1.0
7+
taxonomy with a 0-4 score scale (HF: ``TechWolf/Skill-extraction-House-graded``,
8+
BEIR layout, validation split only).
9+
- ``TechGradedSkillExtractRanking``: graded-relevance skill-extraction ranking
10+
task on the TECH subset of CAREER, re-annotated against the full ESCO v1.1.0
11+
taxonomy with a 0-4 score scale (HF: ``TechWolf/Skill-extraction-Tech-graded``,
12+
BEIR layout, validation split only).
13+
- ``SkillSkapeGradedSkillExtractRanking``: graded-relevance skill-extraction
14+
ranking task on SkillSkape, re-annotated against the full ESCO v1.1.0 taxonomy
15+
with a 0-4 score scale (HF: ``TechWolf/Skill-extraction-SkillSkape-graded``,
16+
BEIR layout, validation split only).
17+
- ``ESCOGradedSkillNormRanking``: graded-relevance skill-normalization ranking
18+
task that maps surface skill terms (ESCO alt-labels) to canonical ESCO skills,
19+
annotated against the full ESCO v1.1.0 taxonomy with a 0-4 score scale
20+
(HF: ``TechWolf/Skill-normalisation-ESCO-graded``, BEIR layout, validation
21+
split only).
522
- graded relevance support for ranking metrics: ``RankingDataset`` accepts an
623
optional ``target_relevance`` field aligned 1-to-1 with ``target_indices``.
724
``ndcg@k`` uses a (2^rel - 1) gain when graded labels are provided; binary

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,16 @@ pip install workrb
8585
| Job Normalization MELO | `MELORanking` | multi_label | 633 queries x 33813 targets | 21 |
8686
| Skill to Job WorkBench | `ESCOSkill2JobRanking` | multi_label | 13492 queries x 3039 targets | 28 |
8787
| Skill Extraction House | `HouseSkillExtractRanking` | multi_label | 262 queries x 13891 targets | 28 |
88+
| Skill Extraction House Graded | `HouseGradedSkillExtractRanking` | multi_label | 61 queries x 13891 targets | 1 |
8889
| Skill Extraction Tech | `TechSkillExtractRanking` | multi_label | 338 queries x 13891 targets | 28 |
90+
| Skill Extraction Tech Graded | `TechGradedSkillExtractRanking` | multi_label | 75 queries x 13891 targets | 1 |
8991
| Skill Extraction SkillSkape | `SkillSkapeExtractRanking` | multi_label | 1191 queries x 13891 targets | 28 |
92+
| Skill Extraction SkillSkape Graded | `SkillSkapeGradedSkillExtractRanking` | multi_label | 100 queries x 13891 targets | 1 |
9093
| Skill Extraction TechWolf | `TechWolfSkillExtractRanking` | multi_label | 326 queries x 13891 targets | 28 |
9194
| Skill Extraction SkillXL | `SkillXLSkillExtractRanking` | multi_label | 944 queries x 13891 targets | 28 |
9295
| Skill Similarity SkillMatch-1K | `SkillMatch1kSkillSimilarityRanking` | single_label | 900 queries x 2648 targets | 1 |
9396
| Skill Normalization ESCO | `ESCOSkillNormRanking` | multi_label | 72008 queries x 13939 targets | 28 |
97+
| Skill Normalization ESCO Graded | `ESCOGradedSkillNormRanking` | multi_label | 50 queries x 13891 targets | 1 |
9498
| Skill Normalization MELS | `MELSRanking` | multi_label | 1722 queries x 19466 targets | 5 |
9599
| Query-Candidate Matching | `SearchQueryCandidateRanking` | multi_label | 200 queries x 4019 (x-lang) targets | 5 |
96100
| Project-Candidate Matching | `ProjectCandidateRanking` | multi_label | 200 queries x 4019 (x-lang) targets | 5 |

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ readme = "README.md"
1313
requires-python = ">=3.10,<4.0"
1414
license = { text = "Apache 2.0" }
1515
authors = [
16-
{ name = "TechWolf Research Team" },
17-
{ name = "Matthias De Lange", email = "matthias.delange@techwolf.ai" },
16+
{ name = "TechWolf Research Team", email = "workrb@techwolf.ai" },
1817
]
1918
keywords = [
2019
"nlp",

src/workrb/run.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -783,5 +783,11 @@ def _record_dataset_result(
783783
if config:
784784
config.save_results_checkpoint(results)
785785

786+
# Prefer the task's first default metric, but fall back to whatever was
787+
# actually computed: a caller-supplied `metrics` set need not include the
788+
# default key metric, and indexing it directly would raise KeyError.
786789
key_metric = task.default_metrics[0]
787-
logger.info(f"\t{key_metric}: {metrics_dict[key_metric]:.3f}")
790+
if key_metric not in metrics_dict:
791+
key_metric = next(iter(metrics_dict), None)
792+
if key_metric is not None:
793+
logger.info(f"\t{key_metric}: {metrics_dict[key_metric]:.3f}")

src/workrb/tasks/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@
1919
from .ranking.mels import MELSRanking
2020
from .ranking.skill2job import ESCOSkill2JobRanking
2121
from .ranking.skill_extraction import (
22+
HouseGradedSkillExtractRanking,
2223
HouseSkillExtractRanking,
2324
SkillSkapeExtractRanking,
25+
SkillSkapeGradedSkillExtractRanking,
2426
SkillXLSkillExtractRanking,
27+
TechGradedSkillExtractRanking,
2528
TechSkillExtractRanking,
29+
TechWolfGradedSkillExtractRanking,
2630
TechWolfSkillExtractRanking,
2731
)
2832
from .ranking.skill_similarity import SkillMatch1kSkillSimilarityRanking
29-
from .ranking.skillnorm import ESCOSkillNormRanking
33+
from .ranking.skillnorm import ESCOGradedSkillNormRanking, ESCOSkillNormRanking
3034

3135
__all__ = [
3236
# Abstract classes
@@ -39,17 +43,22 @@
3943
# Classification tasks
4044
"ESCOJob2SkillClassification",
4145
# Ranking tasks
46+
"ESCOGradedSkillNormRanking",
4247
"ESCOJob2SkillRanking",
4348
"ESCOSkill2JobRanking",
4449
"ESCOSkillNormRanking",
4550
"JobBERTJobNormRanking",
4651
"JobTitleSimilarityRanking",
4752
"MELORanking",
4853
"MELSRanking",
54+
"HouseGradedSkillExtractRanking",
4955
"HouseSkillExtractRanking",
56+
"TechGradedSkillExtractRanking",
5057
"TechSkillExtractRanking",
58+
"TechWolfGradedSkillExtractRanking",
5159
"TechWolfSkillExtractRanking",
5260
"SkillSkapeExtractRanking",
61+
"SkillSkapeGradedSkillExtractRanking",
5362
"SkillXLSkillExtractRanking",
5463
"SkillMatch1kSkillSimilarityRanking",
5564
"ProjectCandidateRanking",

src/workrb/tasks/ranking/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,25 @@
1818
from workrb.tasks.ranking.mels import MELSRanking
1919
from workrb.tasks.ranking.skill2job import ESCOSkill2JobRanking
2020
from workrb.tasks.ranking.skill_extraction import (
21+
HouseGradedSkillExtractRanking,
2122
HouseSkillExtractRanking,
2223
SkillSkapeExtractRanking,
24+
SkillSkapeGradedSkillExtractRanking,
2325
SkillXLSkillExtractRanking,
26+
TechGradedSkillExtractRanking,
2427
TechSkillExtractRanking,
28+
TechWolfGradedSkillExtractRanking,
2529
TechWolfSkillExtractRanking,
2630
)
2731
from workrb.tasks.ranking.skill_similarity import SkillMatch1kSkillSimilarityRanking
28-
from workrb.tasks.ranking.skillnorm import ESCOSkillNormRanking
32+
from workrb.tasks.ranking.skillnorm import ESCOGradedSkillNormRanking, ESCOSkillNormRanking
2933

3034
__all__ = [
35+
"ESCOGradedSkillNormRanking",
3136
"ESCOJob2SkillRanking",
3237
"ESCOSkill2JobRanking",
3338
"ESCOSkillNormRanking",
39+
"HouseGradedSkillExtractRanking",
3440
"HouseSkillExtractRanking",
3541
"JobBERTJobNormRanking",
3642
"JobTitleSimilarityRanking",
@@ -40,7 +46,10 @@
4046
"SearchQueryCandidateRanking",
4147
"SkillMatch1kSkillSimilarityRanking",
4248
"SkillSkapeExtractRanking",
49+
"SkillSkapeGradedSkillExtractRanking",
4350
"SkillXLSkillExtractRanking",
51+
"TechGradedSkillExtractRanking",
4452
"TechSkillExtractRanking",
53+
"TechWolfGradedSkillExtractRanking",
4554
"TechWolfSkillExtractRanking",
4655
]
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Shared base for BEIR-layout graded-relevance ranking tasks.
2+
3+
Several tasks publish graded (0-4) relevance annotations on the Hugging Face
4+
Hub following the BEIR convention (``queries``, ``corpus``, ``qrels`` configs).
5+
They differ only in their task group and query input type (e.g. skill
6+
extraction from sentences vs skill normalization from surface terms), so the
7+
data-loading logic lives here and concrete tasks supply the task-specific
8+
properties.
9+
"""
10+
11+
import pandas as pd
12+
from datasets import Dataset, load_dataset
13+
14+
from workrb.tasks.abstract.base import DatasetSplit, LabelType, Language
15+
from workrb.tasks.abstract.ranking_base import RankingDataset, RankingTask
16+
from workrb.types import ModelInputType
17+
18+
19+
class GradedBEIRRankingTask(RankingTask):
20+
"""Base class for BEIR-layout graded ranking tasks.
21+
22+
Reads the ``queries``, ``corpus`` and ``qrels`` configs published on the
23+
Hugging Face Hub via ``load_dataset``. The target_space is the corpus's
24+
``title`` column (ESCO preferred labels, in corpus order); qrels are
25+
expected to contain only non-zero judgments (absent items are implicit
26+
grade 0).
27+
28+
Which splits a task exposes depends on what the underlying dataset
29+
publishes: some release both a validation and a test split, others only
30+
one. Because several tasks share this loader, the supported splits are
31+
declared per subclass via :attr:`split_to_hf_split` (which maps each
32+
supported :class:`DatasetSplit` to the HF split name its
33+
``queries``/``qrels`` configs live under) rather than hardcoded with an
34+
inline guard as in the single-split tasks (e.g. ``MELORanking``). The
35+
default exposes only the validation split, the common case for in-progress
36+
benchmark datasets whose test split is withheld.
37+
38+
Concrete subclasses set ``hf_name`` via ``__init__`` and supply the
39+
task-specific ``task_group``, ``query_input_type``, ``name``,
40+
``description`` and ``citation``.
41+
"""
42+
43+
def __init__(self, hf_name: str, **kwargs):
44+
"""Initialize the task.
45+
46+
Args:
47+
hf_name: Name of the Hugging Face dataset (BEIR layout).
48+
**kwargs: Additional arguments for the base class.
49+
"""
50+
self.hf_name = hf_name
51+
super().__init__(**kwargs)
52+
53+
@property
54+
def split_to_hf_split(self) -> dict[DatasetSplit, str]:
55+
"""Map each supported split to the HF split name backing it.
56+
57+
The corpus config is always loaded from the ``corpus`` split; this
58+
mapping only governs the ``queries`` and ``qrels`` configs. Override to
59+
expose more or fewer splits, e.g. ``{DatasetSplit.VAL: "validation",
60+
DatasetSplit.TEST: "test"}`` for a dataset that releases both.
61+
"""
62+
return {DatasetSplit.VAL: "validation"}
63+
64+
@property
65+
def supported_query_languages(self) -> list[Language]:
66+
"""Annotations are released in English only at this stage."""
67+
return [Language.EN]
68+
69+
@property
70+
def supported_target_languages(self) -> list[Language]:
71+
"""The corpus titles are released in English only at this stage."""
72+
return [Language.EN]
73+
74+
@property
75+
def label_type(self) -> LabelType:
76+
"""Label type is multi-label."""
77+
return LabelType.MULTI_LABEL
78+
79+
@property
80+
def target_input_type(self) -> ModelInputType:
81+
"""Target input type for ESCO skills."""
82+
return ModelInputType.SKILL_NAME
83+
84+
@property
85+
def default_metrics(self) -> list[str]:
86+
"""Default metrics include nDCG to leverage the graded labels.
87+
88+
``ndcg`` without a cutoff scores the full ranking (k = |target_space|).
89+
"""
90+
return ["ndcg", "ndcg@5", "ndcg@10", "map", "rp@10", "mrr"]
91+
92+
def load_dataset(self, dataset_id: str, split: DatasetSplit) -> RankingDataset:
93+
"""Load BEIR-style graded annotations and convert to a RankingDataset."""
94+
hf_split = self.split_to_hf_split.get(split)
95+
if hf_split is None:
96+
supported = ", ".join(sorted(s.value for s in self.split_to_hf_split))
97+
raise ValueError(
98+
f"Split '{split.value}' not supported for {type(self).__name__}: "
99+
f"only [{supported}] {'is' if len(self.split_to_hf_split) == 1 else 'are'} "
100+
f"annotated for this dataset."
101+
)
102+
103+
queries_ds = load_dataset(self.hf_name, "queries", split=hf_split)
104+
corpus_ds = load_dataset(self.hf_name, "corpus", split="corpus")
105+
qrels_ds = load_dataset(self.hf_name, "qrels", split=hf_split)
106+
assert isinstance(queries_ds, Dataset)
107+
assert isinstance(corpus_ds, Dataset)
108+
assert isinstance(qrels_ds, Dataset)
109+
queries_df = queries_ds.to_pandas()
110+
corpus_df = corpus_ds.to_pandas()
111+
qrels_df = qrels_ds.to_pandas()
112+
assert isinstance(queries_df, pd.DataFrame)
113+
assert isinstance(corpus_df, pd.DataFrame)
114+
assert isinstance(qrels_df, pd.DataFrame)
115+
116+
# target_space is the corpus titles in corpus order; URIs map by row index.
117+
target_space = corpus_df["title"].tolist()
118+
uri_to_idx = {uri: i for i, uri in enumerate(corpus_df["_id"])}
119+
120+
qrels_df["target_idx"] = qrels_df["corpus-id"].map(uri_to_idx)
121+
# Every qrel should resolve; if any don't, surface the issue rather than silently dropping.
122+
unresolved = qrels_df["target_idx"].isna().sum()
123+
assert unresolved == 0, (
124+
f"{unresolved} qrel rows reference corpus-ids not present in the corpus config"
125+
)
126+
qrels_df["target_idx"] = qrels_df["target_idx"].astype(int)
127+
128+
id_to_query = dict(zip(queries_df["_id"], queries_df["text"], strict=True))
129+
qrels_df["sentence"] = qrels_df["query-id"].map(id_to_query)
130+
131+
grouped = qrels_df.groupby("sentence")
132+
filtered_queries: list[str] = []
133+
filtered_indices: list[list[int]] = []
134+
filtered_relevance: list[list[float]] = []
135+
for sentence, group in grouped:
136+
filtered_queries.append(str(sentence))
137+
filtered_indices.append(group["target_idx"].tolist())
138+
filtered_relevance.append([float(s) for s in group["score"].tolist()])
139+
140+
return RankingDataset(
141+
query_texts=filtered_queries,
142+
target_indices=filtered_indices,
143+
target_space=target_space,
144+
dataset_id=dataset_id,
145+
target_relevance=filtered_relevance,
146+
)

0 commit comments

Comments
 (0)