Tutorial: Heterogeneous Source Repetitions
This tutorial walks through the experimental relation pipeline with the sample
fixtures shipped in examples/sample_data/heterogeneous/.
The example has five physical samples. Each sample is measured by three sources, but each source has its own repetition count:
Source |
Repetitions per sample |
Example files |
|---|---|---|
MIR |
2 |
|
RAMAN |
3 |
|
NIRS |
2 |
|
This is not the same as legacy repetition="sample_id", where all rows are
already in one rectangular table. Here, each source first needs an explicit
sample identity contract.
1. Start From A Relation Config
The simplest example is:
examples/configs/datasets/heterogeneous_repetitions_per_source_aggregate.yaml
It declares the sample key, the source-specific repetition columns, the expected cardinalities, and the first materialized representation:
experimental_relation_pipeline: true
repetition_spec:
sample_id: sample_id
link_by: sample_id
target_level: physical_sample
rep_order: exchangeable
strict_cardinality: true
missing_repetition_policy: strict
missing_source_policy: strict
sources:
MIR: {rep_col: rep, expected: 2}
RAMAN: {rep_col: rep, expected: 3}
NIRS: {rep_col: rep, expected: 2}
representations:
- name: per_source_aggregate
unit_level: sample
method: mean
2. Parse The Contract
Use parse_config to load the YAML and RepetitionSpec to normalize the
relation contract:
from pathlib import Path
from nirs4all.data.config_parser import parse_config
from nirs4all.data.relations import RepetitionSpec
examples_root = Path("examples")
config_path = examples_root / "configs/datasets/heterogeneous_repetitions_per_source_aggregate.yaml"
config, name = parse_config(str(config_path))
spec = RepetitionSpec.from_config(config["repetition_spec"])
assert config["experimental_relation_pipeline"] is True
assert name == "heterogeneous_repetitions_per_source_aggregate"
3. Build A Raw Multi-source Dataset
RawMultiSourceDataset is the staging object. It keeps the source tables
separate until you choose an explicit representation.
import csv
import numpy as np
from nirs4all.data.raw_multisource import RawMultiSourceDataset
def read_target_map(path: Path) -> dict[str, float]:
with path.open(newline="") as handle:
return {row["sample_id"]: float(row["target"]) for row in csv.DictReader(handle)}
def read_source(path: Path) -> tuple[np.ndarray, list[str], list[str], list[str]]:
with path.open(newline="") as handle:
rows = list(csv.DictReader(handle))
feature_headers = [column for column in rows[0] if column not in {"sample_id", "rep"}]
X = np.asarray([[float(row[column]) for column in feature_headers] for row in rows])
sample_ids = [row["sample_id"] for row in rows]
rep_ids = [row["rep"] for row in rows]
return X, sample_ids, rep_ids, feature_headers
# parse_config normalizes shared_targets to targets.
target_by_sample = read_target_map(examples_root / config["targets"]["path"])
X_by_source = {}
keys_by_source = {}
rep_by_source = {}
headers_by_source = {}
targets_by_source = {}
for index, source in enumerate(config["sources"]):
source_id = source["name"]
X, sample_ids, rep_ids, headers = read_source(examples_root / source["train_x"])
X_by_source[source_id] = X
keys_by_source[source_id] = sample_ids
rep_by_source[source_id] = rep_ids
headers_by_source[source_id] = headers
# One source is enough to carry sample-level targets; they are linked by sample_id.
if index == 0:
targets_by_source[source_id] = [target_by_sample[sample_id] for sample_id in sample_ids]
raw = RawMultiSourceDataset.from_sources(
spec,
X_by_source,
keys_by_source,
headers_by_source=headers_by_source,
rep_by_source=rep_by_source,
targets_by_source=targets_by_source,
)
At this point, no rectangular model matrix has been created yet.
print(raw.physical_sample_ids)
# ['S001', 'S002', 'S003']
print(raw.cardinalities()[("S001", "MIR")])
# 2
print(raw.cardinalities()[("S001", "RAMAN")])
# 3
4. Materialize A Sample-level Matrix
per_source_aggregate averages repetitions inside each source, then
concatenates the source blocks in canonical source order. The training fixture
has three physical samples and three wavelengths per source, so the resulting
matrix has shape (3, 9).
materialized = raw.materialize("per_source_aggregate")
model_X, model_headers = materialized.to_feature_matrix()
print(model_X.shape)
# (3, 9)
print(model_headers)
# ['MIR:w1000', 'MIR:w1100', 'MIR:w1200',
# 'NIRS:w1450', 'NIRS:w1550', 'NIRS:w1650',
# 'RAMAN:w500', 'RAMAN:w750', 'RAMAN:w1000']
print(materialized.sample_ids)
# ['S001', 'S002', 'S003']
This representation is the safest default when you want a conventional model: one row per physical sample, no row-count bias from sources with more repetitions.
5. Try A Cartesian Representation
When cross-source feature interactions matter, use a bounded cartesian plan.
For MIR=2, RAMAN=3, and NIRS=2, each sample has 2 * 3 * 2 = 12 combinations.
from nirs4all.data.raw_multisource import RepresentationPlan
cartesian = raw.materialize(
RepresentationPlan(
"cartesian_full",
max_combos_per_sample=12,
max_total_combos=36,
max_total_rows=36,
)
)
print(cartesian.X.shape)
# (36, 9)
print(cartesian.unit_ids[0])
# S001::MIR1xNIRS1xRAMAN1
Always keep a cap on cartesian materialization. It makes the memory and replay contract explicit, and it lets validation fail before allocating a matrix that is larger than expected.
6. Use The Pipeline Boundary
Once a relation-aware dataset is staged, rep_fusion is the pipeline boundary
that converts it into a regular feature matrix for downstream operators:
from sklearn.cross_decomposition import PLSRegression
from sklearn.model_selection import GroupKFold
from sklearn.preprocessing import StandardScaler
from nirs4all.pipeline.config import PipelineConfigs
pipeline = PipelineConfigs(
[
{"rep_fusion": "per_source_aggregate"},
StandardScaler(),
GroupKFold(n_splits=3),
{"model": PLSRegression(n_components=2)},
]
)
In relation-aware runs, keep splitting and scoring at the physical-sample level. The replay manifest stored with the run records the representation, reducers, missingness policy, and fit-influence contract needed for bundle prediction.
7. Compare The Shipped Examples
The repository includes four relation-contract examples:
Config |
Use when |
|---|---|
|
You want one conventional row per physical sample. |
|
You want source-level branches and sample-aligned meta features. |
|
You need explicit cross-source combo rows with sample-level reducers. |
|
Prediction can miss a declared source and the model expects masks/padding. |
Mirrors are available under examples/sample_configs/datasets/G01_... through
G04_... for test-dataset discovery.
Note
The Category G YAML files are experimental relation contracts. They are parsed
and covered by relation-table smoke tests, but they are not legacy
DatasetConfigs loader fixtures. Use them as contracts for source-aware
staging, materialization, and replay.