Skip to content

Source MixStyle

neureptrace.decoding.source_mixstyle implements a source-only feature-space MixStyle augmentation for cross-subject domain generalization.

The method estimates per-source-domain feature means and scales, then creates synthetic source rows by mixing each row's domain statistics with another source domain's statistics. Labels are copied from the source rows. Held-out target features and target labels are not part of the API.

This is a Protocol 1 / strict source-only augmentation:

  • uses X_s, y_s, and source-domain ids,
  • does not use X_t,
  • does not use y_t.

neureptrace.decoding.source_mixstyle

Source-only MixStyle augmentation for cross-subject domain generalization.

The helpers in this module implement a feature-space variant of MixStyle for M/EEG transfer experiments. Synthetic source rows are generated by replacing a trial's source-domain style statistics with a convex mixture of its own domain statistics and another source domain's statistics. Labels are copied from the source row; held-out target features and target labels are intentionally absent from the public API.

SourceMixStyleConfig dataclass

Configuration for source-only feature-space MixStyle augmentation.

Source code in src/neureptrace/decoding/source_mixstyle.py
28
29
30
31
32
33
34
35
36
37
@dataclass(frozen=True, slots=True)
class SourceMixStyleConfig:
    """Configuration for source-only feature-space MixStyle augmentation."""

    mixes_per_row: int = DEFAULT_MIXSTYLE_MIXES_PER_ROW
    alpha: float = DEFAULT_MIXSTYLE_ALPHA
    style_strength: float = DEFAULT_MIXSTYLE_STYLE_STRENGTH
    synthetic_weight: float = DEFAULT_MIXSTYLE_SYNTHETIC_WEIGHT
    include_original: bool = True
    random_state: int | None = 13

SourceMixStyleResult dataclass

Augmented source features, labels, weights, and provenance.

Source code in src/neureptrace/decoding/source_mixstyle.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@dataclass(frozen=True, slots=True)
class SourceMixStyleResult:
    """Augmented source features, labels, weights, and provenance."""

    features: np.ndarray
    labels: np.ndarray
    sample_weight: np.ndarray
    domain_ids: np.ndarray
    synthetic_mask: np.ndarray
    metadata: dict[str, Any] = field(default_factory=dict)

    @property
    def n_original(self) -> int:
        """Number of original rows retained in the output."""

        return int(np.count_nonzero(~self.synthetic_mask))

    @property
    def n_synthetic(self) -> int:
        """Number of synthetic rows appended to the output."""

        return int(np.count_nonzero(self.synthetic_mask))

n_original property

Number of original rows retained in the output.

n_synthetic property

Number of synthetic rows appended to the output.

source_mixstyle_config(*, mixes_per_row=DEFAULT_MIXSTYLE_MIXES_PER_ROW, alpha=DEFAULT_MIXSTYLE_ALPHA, style_strength=DEFAULT_MIXSTYLE_STYLE_STRENGTH, synthetic_weight=DEFAULT_MIXSTYLE_SYNTHETIC_WEIGHT, include_original=True, random_state=13)

Normalize user-facing MixStyle augmentation options.

Source code in src/neureptrace/decoding/source_mixstyle.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def source_mixstyle_config(
    *,
    mixes_per_row: int | str = DEFAULT_MIXSTYLE_MIXES_PER_ROW,
    alpha: float | str = DEFAULT_MIXSTYLE_ALPHA,
    style_strength: float | str = DEFAULT_MIXSTYLE_STYLE_STRENGTH,
    synthetic_weight: float | str = DEFAULT_MIXSTYLE_SYNTHETIC_WEIGHT,
    include_original: bool = True,
    random_state: int | str | None = 13,
) -> SourceMixStyleConfig:
    """Normalize user-facing MixStyle augmentation options."""

    return SourceMixStyleConfig(
        mixes_per_row=_normalize_nonnegative_int(mixes_per_row, name="mixes_per_row"),
        alpha=_normalize_positive_float(alpha, name="alpha"),
        style_strength=_normalize_unit_interval(style_strength, name="style_strength"),
        synthetic_weight=_normalize_nonnegative_float(synthetic_weight, name="synthetic_weight"),
        include_original=bool(include_original),
        random_state=None if random_state in {None, "", "none", "None"} else _normalize_nonnegative_int(random_state, name="random_state"),
    )

augment_source_domains_mixstyle(source_features, source_labels, source_domains, *, config=None)

Return source-only MixStyle-augmented feature rows.

Parameters:

Name Type Description Default
source_features Sequence[Sequence[float]] | ndarray

Source feature rows pooled across source subjects or source domains.

required
source_labels Sequence[Any] | ndarray

One source label per feature row. Labels are copied to synthetic rows.

required
source_domains Sequence[Hashable] | ndarray

One source-domain identifier per feature row, typically the source subject.

required
config SourceMixStyleConfig | Mapping[str, Any] | None

MixStyle configuration. A mapping is normalized through :func:source_mixstyle_config.

None

Returns:

Type Description
SourceMixStyleResult

Augmented source rows, copied labels, sample weights, source-domain ids, a synthetic-row mask, and protocol metadata.

Notes

This is a strict source-only Protocol-1 augmentation. It uses X_s, y_s, and source-domain ids only. No held-out target features or target labels are accepted by this API.

Source code in src/neureptrace/decoding/source_mixstyle.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def augment_source_domains_mixstyle(
    source_features: Sequence[Sequence[float]] | np.ndarray,
    source_labels: Sequence[Any] | np.ndarray,
    source_domains: Sequence[Hashable] | np.ndarray,
    *,
    config: SourceMixStyleConfig | Mapping[str, Any] | None = None,
) -> SourceMixStyleResult:
    """Return source-only MixStyle-augmented feature rows.

    Parameters
    ----------
    source_features:
        Source feature rows pooled across source subjects or source domains.
    source_labels:
        One source label per feature row.  Labels are copied to synthetic rows.
    source_domains:
        One source-domain identifier per feature row, typically the source subject.
    config:
        MixStyle configuration.  A mapping is normalized through
        :func:`source_mixstyle_config`.

    Returns
    -------
    SourceMixStyleResult
        Augmented source rows, copied labels, sample weights, source-domain ids,
        a synthetic-row mask, and protocol metadata.

    Notes
    -----
    This is a strict source-only Protocol-1 augmentation.  It uses ``X_s``,
    ``y_s``, and source-domain ids only.  No held-out target features or target
    labels are accepted by this API.
    """

    cfg = _coerce_config(config)
    features = _feature_matrix(source_features, name="source_features")
    labels = _label_vector(source_labels, expected_length=features.shape[0], name="source_labels")
    domains = _domain_vector(source_domains, expected_length=features.shape[0])
    unique_domains = tuple(dict.fromkeys(domains.tolist()))
    if len(unique_domains) < 2 and cfg.mixes_per_row > 0:
        raise ValueError("MixStyle source-domain augmentation requires at least two source domains.")
    stats = _domain_stats(features, domains, unique_domains)

    if cfg.mixes_per_row == 0:
        return _original_only_result(features, labels, domains, cfg=cfg, n_domains=len(unique_domains))

    rng = np.random.default_rng(cfg.random_state)
    synthetic_features: list[np.ndarray] = []
    synthetic_labels: list[Any] = []
    synthetic_domains: list[Hashable] = []
    synthetic_lambdas: list[float] = []
    synthetic_partner_domains: list[Hashable] = []

    for row_index, row in enumerate(features):
        own_domain = domains[row_index]
        own_stats = stats[own_domain]
        partner_pool = tuple(domain for domain in unique_domains if domain != own_domain)
        for _ in range(cfg.mixes_per_row):
            partner_domain = partner_pool[int(rng.integers(0, len(partner_pool)))]
            partner_stats = stats[partner_domain]
            lam = float(rng.beta(cfg.alpha, cfg.alpha))
            mixed = mixstyle_row(
                row,
                source_stats=own_stats,
                partner_stats=partner_stats,
                lam=lam,
                style_strength=cfg.style_strength,
            )
            synthetic_features.append(mixed)
            synthetic_labels.append(labels[row_index])
            synthetic_domains.append(own_domain)
            synthetic_lambdas.append(lam)
            synthetic_partner_domains.append(partner_domain)

    synthetic_matrix = np.vstack(synthetic_features).astype(np.float32, copy=False)
    synthetic_label_vector = np.asarray(synthetic_labels, dtype=labels.dtype)
    synthetic_domain_vector = np.asarray(synthetic_domains, dtype=object)
    if cfg.include_original:
        output_features = np.vstack([features, synthetic_matrix]).astype(np.float32, copy=False)
        output_labels = np.concatenate([labels, synthetic_label_vector])
        output_domains = np.concatenate([domains, synthetic_domain_vector])
        synthetic_mask = np.concatenate([np.zeros(features.shape[0], dtype=bool), np.ones(synthetic_matrix.shape[0], dtype=bool)])
        sample_weight = np.concatenate([
            np.ones(features.shape[0], dtype=float),
            np.full(synthetic_matrix.shape[0], float(cfg.synthetic_weight), dtype=float),
        ])
    else:
        output_features = synthetic_matrix
        output_labels = synthetic_label_vector
        output_domains = synthetic_domain_vector
        synthetic_mask = np.ones(synthetic_matrix.shape[0], dtype=bool)
        sample_weight = np.full(synthetic_matrix.shape[0], float(cfg.synthetic_weight), dtype=float)

    metadata = _metadata(
        cfg=cfg,
        n_input_rows=features.shape[0],
        n_output_rows=output_features.shape[0],
        n_synthetic=int(np.count_nonzero(synthetic_mask)),
        n_domains=len(unique_domains),
        feature_dim=features.shape[1],
        synthetic_lambdas=synthetic_lambdas,
        synthetic_partner_domains=synthetic_partner_domains,
    )
    return SourceMixStyleResult(
        features=output_features,
        labels=output_labels,
        sample_weight=sample_weight,
        domain_ids=output_domains,
        synthetic_mask=synthetic_mask,
        metadata=metadata,
    )

mixstyle_row(row, *, source_stats, partner_stats, lam, style_strength=DEFAULT_MIXSTYLE_STYLE_STRENGTH)

Transform one feature row with mixed source-domain style statistics.

Source code in src/neureptrace/decoding/source_mixstyle.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def mixstyle_row(
    row: Sequence[float] | np.ndarray,
    *,
    source_stats: Any,
    partner_stats: Any,
    lam: float,
    style_strength: float = DEFAULT_MIXSTYLE_STYLE_STRENGTH,
) -> np.ndarray:
    """Transform one feature row with mixed source-domain style statistics."""

    vector = np.asarray(row, dtype=float).reshape(-1)
    if vector.ndim != 1 or vector.size < 1:
        raise ValueError("row must be a one-dimensional feature vector.")
    lam = _normalize_unit_interval(lam, name="lam")
    style_strength = _normalize_unit_interval(style_strength, name="style_strength")
    source = _coerce_stats(source_stats, expected_width=vector.shape[0], name="source_stats")
    partner = _coerce_stats(partner_stats, expected_width=vector.shape[0], name="partner_stats")
    mixed_mean = lam * source.mean + (1.0 - lam) * partner.mean
    mixed_scale = lam * source.scale + (1.0 - lam) * partner.scale
    standardized = (vector - source.mean) / source.scale
    styled = standardized * mixed_scale + mixed_mean
    return ((1.0 - style_strength) * vector + style_strength * styled).astype(np.float32, copy=False)