]> Piment Noir Git Repositories - freqai-strategies.git/commitdiff
feat(weights): per-label sample weights propagated to model.fit(sample_weight=.....
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sun, 24 May 2026 23:09:27 +0000 (01:09 +0200)
committerGitHub <noreply@github.com>
Sun, 24 May 2026 23:09:27 +0000 (01:09 +0200)
* chore(quickadapter): bump strategy and regressor version 3.11.8 → 3.11.9

* feat(weights): add compose_sample_weights helper with mean=1 multiplicative composition

AFML §4.10 / mlfinpy canonical: per-label mean=1 normalization, multiplicative composition with temporal decay, geometric-mean aggregation for multi-label, NaN/inf handling, all-zero degenerate fallback. Validated locally with pytest (evidence: .omo/evidence/task-5-{red,green}.txt).

* fix(weights): persist label weights into <label>_weight column instead of rescaling target

Removes statistically incorrect target rescaling (label = direction × weight). Persists raw direction labels and a separate <label>_weight column for downstream sample_weight composition. Validated locally with pytest (evidence: .omo/evidence/task-6-{red,green}.txt).

* feat(weights): add _strip_label_weight_columns helper for find_labels collision avoidance

* feat(weights): compose per-label weights with temporal decay before model.fit

* feat(weights): integrate sample_weight composition into both train() data split paths

Add _train_default() mirroring BaseRegressionModel.train() with _compose_train_weights inserted between make_train_test_datasets and _apply_pipelines. Routes train_test_split path through _train_default instead of super().train(). Inserts _compose_train_weights before _apply_pipelines in timeseries_split path. Calls _strip_label_weight_columns(dk) at top of train() for both branches. Validated locally with pytest + structural AST checks (evidence: .omo/evidence/task-9-{pytest,structural}.txt).

* refactor(weights): align _train with BaseRegressionModel.train

Rename _train_default to _train to match the upstream method name (with
underscore prefix to mark it as the internal mirror, since the public
train() method routes between data split paths).

Mirror BaseRegressionModel.train line-for-line with _compose_train_weights
as the single intentional insertion between make_train_test_datasets and
the pipeline application:

- Drop ensure_datetime_series wrapper around unfiltered_df['date']:
  upstream calls .iloc[].strftime() directly.
- Drop **kwargs from self.fit(dd, dk) to match upstream signature.
- Use dk.data_dictionary['train_features'].columns for feature count log,
  matching upstream source of truth.
- Apply the same cosmetic alignment to the timeseries_split path for
  consistency between both train code paths.
- Add docstring documenting the mirror relationship and the single
  functional difference.

* refactor(weights): drop dead apply_label_weighting wrapper

Now that QuickAdapterV3.set_freqai_targets persists raw label direction
into the label column and weights into <label>_weight (consumed by
sample_weight downstream), the apply_label_weighting wrapper that
multiplied label values by their weights is no longer used.

- Drop Utils.apply_label_weighting (returned (weighted_label, weights)).
- Drop Utils._apply_label_weights (the values × weights helper).
- Switch QuickAdapterV3.set_freqai_targets to call compute_label_weights
  directly (already used internally by the removed wrapper).

* refactor(weights): simplify MAXIMA/MINIMA plot columns for binary direction

Now that the label column holds raw direction in {-1, 0, +1}, the MAXIMA
and MINIMA plot columns reduce to a where(direction>0, 0.0) /
where(direction<0, 0.0) projection.

The previous magnitude-aware logic (plot_eps padding, mask of zero values
on positive direction, etc.) was tailored to the weighted label
amplitudes and is now dead code:

- extrema.abs().where(extrema.ne(0.0)).min() always evaluates to 1.0,
  so plot_eps is always max(0.5, _PLOT_EXTREMA_MIN_EPS) = 0.5.
- direction.gt(0) & extrema.eq(0.0) is always False because direction>0
  implies extrema = +1, never 0. The .mask() branches never trigger.

Drop the now-unused _PLOT_EXTREMA_MIN_EPS class constant.

* refactor(weights): rename smooth_label to smooth for genericity

The function applies generic smoothing kernels (gaussian, kaiser, triang,
smm, sma, savgol, gaussian_filter1d) to any pd.Series. The 'label'
suffix narrowed it to label-specific use, but it now also smooths the
<label>_weight column (next commit). Drop the suffix; the smoothing
config dict is still named label_smoothing because the per-column
config map remains label-keyed.

* feat(weights): smooth <label>_weight column with the same kernel as label

Per-label weights are pointwise: only pivot indices carry the
metric-derived weight, while non-pivot indices are filled with the
median weight (see compute_label_weights / _build_weights_array).

The label column is smoothed with smooth() to spread pivot signals
over neighbouring candles. Without smoothing the weight column, the
smoothed label values around a pivot keep the constant median weight,
so the model treats high-amplitude pivot neighbours and the pivot
itself as equally important during training.

Apply smooth() to the <label>_weight column with the exact same
per-column smoothing config as the label, so the weight profile
follows the label profile candle-for-candle.

The smooth() positional argument list was redundant with the
col_smoothing_config dict keys; collapse both calls to **kwargs
unpacking. _SMOOTHING_SPECS keys exactly match smooth() parameter
names, so the unpack is type-safe.

compose_sample_weights already replaces non-finite or non-positive
values with 1.0, which absorbs any sign overshoot from kernels like
savgol at series edges.

* feat(weights): add direction and weight subplots showing raw + smoothed signals

Replace the MAXIMA/MINIMA bar visualization with two new subplots that
show both the raw and the smoothed direction/weight curves:

- Subplot 'direction' overlays raw direction (extrema_direction) and
  smoothed direction (smoothed_extrema).
- Subplot 'weight' overlays raw weight (extrema_weight) and smoothed
  weight (smoothed_extrema_weight).

Each visualization column is captured at the right point in
set_freqai_targets:
- Raw columns (EXTREMA_DIRECTION_COLUMN, EXTREMA_WEIGHT_COLUMN) are
  written before the smooth() call.
- Smoothed columns (SMOOTHED_EXTREMA_COLUMN, SMOOTHED_EXTREMA_WEIGHT_COLUMN)
  are written after the smooth() call.

Drop MAXIMA_COLUMN and MINIMA_COLUMN constants — they were only used by
the old min_max bar subplot. The new subplots convey the same direction
information plus the per-pivot weight magnitude that the legacy
weighted_label visualization showed (before the sample-weight refactor).

All four visualization column names lack the '&' prefix, so FreqAI's
find_labels auto-detection ignores them; they cannot leak into model
targets.

* refactor(weights): align visualization column names on extrema_<axis>[_smoothed]

Three-agent audit (explore + librarian + oracle) found the previous viz
column names suffered from three inconsistencies:
- 'extrema_direction' (raw) carries the axis word, but 'smoothed_extrema'
  drops it, breaking the 2x2 grid (axis × stage).
- Stage qualifier appears as PREFIX ('smoothed_extrema') for one column
  and as SUFFIX ('smoothed_extrema_weight') for another.
- 'smoothed_extrema_weight' mixes prefix stage word with suffix axis word.

Production codebases (statsmodels Kalman, bukosabino/ta MACD, mlflow
NPMI, FreqAI's own _mean/_std) overwhelmingly use suffix-decorated
processed forms with the raw form as the plain base. FreqAI's internal
pattern is suffix (&s-extrema_weight, &s-extrema_mean, &s-extrema_std);
align with it.

Rename:
- SMOOTHED_EXTREMA_COLUMN ('smoothed_extrema')
    -> EXTREMA_DIRECTION_SMOOTHED_COLUMN ('extrema_direction_smoothed')
- SMOOTHED_EXTREMA_WEIGHT_COLUMN ('smoothed_extrema_weight')
    -> EXTREMA_WEIGHT_SMOOTHED_COLUMN ('extrema_weight_smoothed')

Result: every viz column follows extrema_<axis>[_smoothed]. The 2x2
grid is uniform, sort-order groups raw and smoothed pairs together,
and the pattern is internally consistent with FreqAI's existing
suffix-based derivations.

* fix(weights): address PR #72 review comments

Three-agent cross-validation (explore + librarian + oracle) of Copilot
review comments produced these verdicts:

C1 (Utils.py:compose_sample_weights) — REAL BUG. Replacing 0-valued
weights with 1.0 silently undoes sklearn / AFML §4.10's canonical 'drop
this sample' semantic. sklearn's _check_sample_weight_equivalence,
DecisionTree _splitter.pyx, HistGBM docs, LightGBM #5553/#905, XGBoost
#3787 and mlfinlab time-decay all converge on the same contract:
sample_weight=0 means 'this sample contributes nothing'. Preserve zeros
via a drop_mask that is OR'd across labels (any label saying 'drop'
wins), then re-applied after the geometric-mean composition. Non-zero
non-finite or negative values still collapse to 1.0 (geometric mean's
neutral element) since they represent undefined weights, not exclusions.

C3/C4 (QuickAdapterRegressorV3.py:_train, timeseries_split) — REAL
REGRESSION. Commit 9953f0c removed ensure_datetime_series with the
rationale 'mirror BaseRegressionModel.train exactly'. But
ensure_datetime_series was introduced in commit ce843f9 specifically
as a workaround for freqtrade issue #13107 (int64 epoch-ms date
columns from feather/parquet handlers). Mirror the algorithm, retain
project-specific safety patches. Restore ensure_datetime_series in
both train paths.

C2/C7 (_label_weight_column_name unused) — DRY violation. Both call
sites in _compose_train_weights now use the helper instead of inline
f-strings.

C8 (train() docstring) — Inaccurate. The default path was claimed to
'Delegate to BaseRegressionModel.train()' but actually routes to
self._train() (a mirror with weight composition). Fix docstring to
reflect actual control flow.

C5/C6 (**kwargs forwarding) — FALSE POSITIVE. freqai_interface never
passes kwargs to model.train(); upstream BaseRegressionModel.train
also calls self.fit(dd, dk) without kwargs. The current code matches
upstream and the call chain is dead in practice.

* refactor(weights): factor train paths and relocate methods for structural coherence

Three-agent structural audit (explore + librarian + oracle) identified
five issues; fixes that don't fight existing conventions:

1. _train and the timeseries_split inline branch in train() shared
   ~30 lines of identical scaffolding (filter_features, dates logging,
   fit_labels guard, weight composition, pipeline application, fit,
   timing logs). Extract _train_common(unfiltered_df, pair, dk, split_fn)
   that owns the full mirror; _train_default and _train_timeseries_split
   become 4-line dispatchers passing the split callback. train() routing
   collapses to a clean two-line if/elif.

2. _label_weight_column_name, _strip_label_weight_columns and
   _compose_train_weights were inserted into the middle of the class
   constants block (between _TEST_SIZE and _SQRT_2), interrupting the
   constant-block coherence. Move them to the private instance method
   zone, immediately after _apply_pipelines (their natural neighbour).

3. _compose_train_weights duplicated the train/test weight extraction
   loop verbatim. Factor into a static _extract_split_weights helper
   that takes a split index and returns the per-label weight map; both
   train and test call sites become single expressions.

4. The four visualization column constants (EXTREMA_DIRECTION_COLUMN,
   EXTREMA_DIRECTION_SMOOTHED_COLUMN, EXTREMA_WEIGHT_COLUMN,
   EXTREMA_WEIGHT_SMOOTHED_COLUMN) were 75 lines below EXTREMA_COLUMN /
   LABEL_COLUMNS, separated by the LabelData dataclass and label
   generator registry. Move them next to EXTREMA_COLUMN where they
   logically belong.

5. Revert the train() docstring bullet to its upstream form. The
   modification introduced RST cross-reference syntax inconsistent with
   the surrounding plain-text docstring style.

* refactor(weights): centralize LABEL_WEIGHT_SUFFIX in Utils

The "_weight" suffix was duplicated as a class constant in
QuickAdapterRegressorV3 and as 5 hardcoded f-strings in QuickAdapterV3.
Three-agent audit (explore + librarian + oracle) converged on moving
this column-naming convention to Utils.py:

- PEP 8 default for constants is module-level; class-level is the
  exception for class-private semantics.
- Both consumers already import column-naming constants from Utils.py
  (LABEL_COLUMNS, EXTREMA_COLUMN, EXTREMA_WEIGHT_COLUMN, etc.). The
  suffix belongs with them.
- Production precedents (sklearn UNUSED/WARN/UNCHANGED, mlflow
  _SAMPLE_WEIGHT/_TRAINING_PREFIX, lightgbm _DatasetNames, pandas
  LOCAL_TAG) all place cross-module string tokens at module level.
- The constant describes a dataframe schema contract (column names),
  not model behaviour. Schema concerns belong in the schema module.

Add LABEL_WEIGHT_SUFFIX to Utils.py next to LABEL_COLUMNS. Remove the
class-level _LABEL_WEIGHT_SUFFIX in QuickAdapterRegressorV3 and import
the module-level constant. Replace 5 hardcoded f-strings in
QuickAdapterV3 with a local label_weight_col binding using the
imported constant.

The private _label_weight_column_name helper is kept in the regressor
since it is used twice (in _strip_label_weight_columns and
_compose_train_weights) and still adds a thin DRY layer over the
suffix synthesis.

* style: apply ruff formatting

* feat(weights): add label_weight_column helper with regex prefix strip and collision assertion

* fix(weights): preserve drop_mask and prevent NaN in compose_sample_weights fallback

* refactor(weights): adopt label_weight_column helper for canonical training column

* feat(weights): add _build_per_row_weights helper for pre-split weight composition

* feat(weights): add _make_default_split_datasets mirror with sklearn-key whitelist

* refactor(weights): make timeseries split helper accept external weights parameter

* refactor(weights): refactor _train_common chain and delete obsolete weight helpers

* refactor(weights): replace train() if/elif with dispatch dict and add weight-column uniqueness check

* fix(weights): harden compose_sample_weights for degenerate inputs

Address audit findings A0-1, A0-2, A0-3, A0-4, P2 #6, P2 #9, P2 #11
on branch feat/per-label-sample-weights.

- Extract _sanitize_and_renormalize private helper with four-guard chain
  (positive sum, finite sum, finite ratio, finite scaled) and uniform
  fallback. Used at empty-map fast path and at final fallback site.
- Empty label_weights_map now sanitizes raw temporal (A0-3).
- Subnormal temporal no longer overflows: ratio + scaled both checked
  for finiteness before returning (A0-2).
- Drop predicate unified as 'arr <= 0 or non-finite' instead of exact
  zero, eliminating the discontinuity at zero from smoothing artifacts
  (A0-4); negatives now drop, no longer rescued to 1.0 (subsumes A1-10).
- Surviving positive values floored at np.finfo(float).tiny to prevent
  subnormal arithmetic in the geo-mean log step.
- drop_mask covering all rows now raises ValueError instead of silently
  returning all-zero weights that crash XGBoost / sklearn HGBR (A0-1).
- Up-front per-label shape validation raises a precise error instead of
  letting numpy broadcasting fail mid-computation (P2 #11).

* docs(weights): document compose_sample_weights contract

Address audit findings A1-2, P2 #12, A1-12 on branch
feat/per-label-sample-weights.

Add 11-line docstring covering: output invariant (mean=1), per-label
sanitization predicate, aggregation operator, drop semantics, error
conditions, and the bounded full-series-median leakage in
compute_label_weights.

* refactor(weights): inline data-split dispatch with match/case

Address audit findings A1-1, A1-7, A1-6 (obsolete), P2 #14 on branch
feat/per-label-sample-weights. Conflict C1: A1-7 wins over A1-3
(YAGNI on subclass extension; LSP traceability preserved).

- Delete _DATA_SPLIT_DISPATCH class attribute (4 LOC).
- Delete _data_split_methods_set lru_cached helper (4 LOC dead code).
- Delete _train_default and _train_timeseries_split wrappers (~30 LOC
  pure boilerplate dispatching to _train_common).
- Inline match/case on method name in train(); pick the right
  _make_*_split_datasets via a local split_builder; nested split_fn
  closes over dk. Net -37 LOC; full LSP traceability.
- Add SplitFn module-level type alias used in _train_common signature.

* fix(weights): reject bool config values in numeric validators

Address audit findings A1-4, P2 #5, P2 #7 on branch
feat/per-label-sample-weights.

Python's `bool` is an `int` subclass, so `isinstance(True, int)` is
true and config values of `true`/`false` silently passed through as
`1`/`0` in the validators for n_splits, gap, max_train_size,
test_size and weight_factor. This commit closes that footgun:

- Add static helpers _coerce_int (always returns int, raises on bool
  or non-int) and _coerce_optional_int (returns Optional[int]) to
  centralize the validation; both echo the offending raw value via
  `{value!r}` so the diagnostic shows True/False rather than 1/0.
- Apply _coerce_int to n_splits and gap, _coerce_optional_int to
  max_train_size in _make_timeseries_split_datasets.
- Add explicit bool guard for test_size in both default-split and
  timeseries-split paths; previously test_size=true would slip past
  isinstance(_, int) and silently train on 1 sample.
- Add explicit bool guard for weight_factor before the >0 comparison.

* fix(weights): preserve mean=1 invariant across pipeline stages

Address audit finding A1-5 on branch feat/per-label-sample-weights.

compose_sample_weights guarantees sum(w)==N (mean(w)==1) over the full
training series, but this invariant breaks twice downstream: (1) the
train/test split partitions weights into disjoint subsets whose means
no longer equal 1, and (2) feature_pipeline.fit_transform may drop
rows via SVM/DBSCAN, drifting the means further. XGBoost
min_child_weight, LightGBM min_sum_hessian_in_leaf and L2
regularization are all sensitive to absolute weight scale.

- Add static helper _renormalize_to_unit_mean with the same four-guard
  chain as _sanitize_and_renormalize (positive sum, finite sum, finite
  ratio, finite scaled, uniform fallback).
- Apply at four sites: before dk.build_data_dictionary in both
  _make_default_split_datasets and _make_timeseries_split_datasets,
  and after feature_pipeline.fit_transform / .transform in
  _apply_pipelines (train and test sides).

* feat(weights): make label-weights aggregation configurable

Address audit finding A1-11 on branch feat/per-label-sample-weights.
Conflict C3: switch default to arithmetic_mean (matching
_compute_combined_label_weights), expose all 6 aggregations via
existing _aggregate_metrics infrastructure.

The hardcoded geometric mean over per-label normalized arrays was
mathematically conservative (one weak label dominates) and inconsistent
with the project's _compute_combined_label_weights default
(arithmetic_mean). For PR #44's correlated multi-target labels
(amplitude, time_to_pivot, efficiency, natr all derived from zigzag),
geomean over-counts redundant evidence and silently degrades to ~0
when any single factor is small. AFML \xc2\xa74.4 recommends arithmetic-mean
equivalents for correlated meta-labels.

- Add aggregation parameter to compose_sample_weights with default
  COMBINED_AGGREGATIONS[0] ("arithmetic_mean"); also expose
  softmax_temperature.
- Delegate the row-wise aggregation step to _aggregate_metrics, reusing
  the existing 6-operator infrastructure with uniform unit coefficients.
- Read both knobs from feature_parameters in _build_per_row_weights:
  label_weights_aggregation and label_weights_softmax_temperature.

* refactor(weights): align naming on compose/sigil/base_weights

Address audit findings A1-9, A1-14, A1-15, A1-16 on branch
feat/per-label-sample-weights.

- Rename _LABEL_WEIGHT_PREFIX_PATTERN to _FREQAI_LABEL_SIGIL_PATTERN:
  the regex strips the freqtrade-native '&' sigil, not a label-weight
  prefix; the new name describes what is matched (Utils.py).
- Rename compose_sample_weights parameter `temporal` to `base_weights`:
  the parameter accepts any base vector (recency weights or uniform
  ones), not exclusively temporal data (Utils.py).
- Rename _build_per_row_weights to _compose_per_row_weights:
  standardize on the 'compose' verb to mirror compose_sample_weights;
  this helper is the orchestrator that calls the kernel
  (QuickAdapterRegressorV3.py).
- Rename _build_weights_array to _scatter_weights: the function
  scatters sparse pivot weights into a dense default-filled array,
  not a generic 'build' (Utils.py).
- Rename eval_set_and_weights to make_test_set_and_weights: aligns
  with FreqAI's 'test_*' data_dictionary vocabulary while avoiding
  the 'test_' prefix that pytest auto-discovers (the verb 'make_'
  also clarifies it as a constructor, not a test) (Utils.py + caller).

* refactor(weights): extract _shuffle_in_unison helper

Address audit findings A1-8 and A1-13 on branch
feat/per-label-sample-weights.

- Extract the train/test shuffle pattern into a static
  _shuffle_in_unison helper. Each call shuffles features, labels and
  weights with the same random seed in lockstep. The shuffle block in
  _make_default_split_datasets shrinks from ~28 lines (two duplicated
  5-line idioms x train+test) to two helper invocations.
- Fix the dk.data_dictionary vs dd inconsistency at the feature-count
  log line: read from local dd (the pipeline's return value) rather
  than dk.data_dictionary (a side-effect set by _apply_pipelines).

* chore(weights): polish naming, validators, caching

Address audit P2 polish items #2, #4, #8, #13, #15 on branch
feat/per-label-sample-weights. P2 #21 and #22 (logger telemetry)
deliberately skipped per the no-new-comments/no-new-infrastructure
constraint; the existing fail-fast ValueError on degenerate inputs
already surfaces the most critical failure modes loudly.

- Counter-based duplicate-label diagnostic now names the offending
  weight columns instead of merely raising on a length mismatch
  (P2 #2).
- Widen shuffle seed space from random.randint(0, 100) (101 distinct
  seeds, birthday collisions at sqrt(101) ~ 10) to randint(0, 2**31-1)
  at both _shuffle_in_unison call sites (P2 #4).
- dsp = dict(self.config['freqai']['data_split_parameters']) replaced
  with self.data_split_parameters (the safe pre-populated FreqAI
  attribute used everywhere else in the file) (P2 #8).
- Cache label_weight_column with @lru_cache(maxsize=16): the helper
  is pure on its single string argument and called in tight loops at
  training; matches the file's existing convention for similar helpers
  (P2 #13).
- Rename loop variable w to label_values in compose_sample_weights;
  the outer scope spans ~20 lines and prior single-letter w obscured
  the role (P2 #15).

* docs(weights): align train() and helper docstrings with current behavior

- Rewrite train() docstring to describe match-based dispatch and the per-row
  weight composition flow through _train_common; remove stale delegation claim.
- Sync _compose_per_row_weights docstring: aggregation default is
  arithmetic_mean, not geometric_mean.
- Fix AFML citation in compose_sample_weights from section 7.4 to chapter 4.
- Document _aggregate_metrics softmax branch as a per-column convex
  combination with explicit T->0 and T->+inf limits.

* refactor(weights): consolidate sample weight renormalization helper

- Promote Utils._sanitize_and_renormalize to public sanitize_and_renormalize.
- Drop QuickAdapterRegressorV3._renormalize_to_unit_mean (cross-file
  duplication of the mean=1 invariant); replace 6 call sites with the
  unified helper. Sites that previously skipped per-element sanitization
  now also reject non-finite or non-positive entries.
- Collapse triple-guard ladder in sanitize_and_renormalize to a single
  finite-positive total check; surviving 'safe' is provably finite-nonneg
  so intermediate isfinite checks were dead code.
- Tighten _shuffle_in_unison signature from Any to concrete pd.DataFrame
  and NDArray types.
- Drop empty-fold sentinel in _make_timeseries_split_datasets and raise
  ValueError on degenerate generator output instead of silently producing
  empty index arrays.

* feat(weights): validate label_weights tunables via label_weighting block

- _compose_per_row_weights now consumes get_label_weighting_config (which
  validates aggregation against COMBINED_AGGREGATIONS and enforces
  softmax_temperature > 0 via _WEIGHTING_SPECS) instead of reading raw
  feature_parameters.label_weights_*.
- Add CONFIG_MIGRATIONS entries auto-migrating
  freqai.feature_parameters.label_weights_aggregation and
  freqai.feature_parameters.label_weights_softmax_temperature to the
  freqai.label_weighting block; users get one warning per key.
- Add module-level _logger to Utils.py and warn on
  compose_sample_weights silent fallback so collapsed-aggregation paths
  are observable.
- _make_timeseries_split_datasets honors reverse_train_test_order for
  parity with _make_default_split_datasets and raises ValueError on
  shuffle_after_split=True (chronological + shuffle is incoherent and
  would leak future data into training).

* fix(weights): seed shuffle deterministically from data_split_parameters.random_state

Replace global random.randint() with a random.Random instance derived from
data_split_parameters.random_state. When the user provides a random_state
(whitelisted in _SKLEARN_TRAIN_TEST_SPLIT_KEYS), train and test shuffles
become reproducible end-to-end; when absent, behavior remains
non-deterministic. The single parent RNG draws two independent sub-seeds
so train and test shuffles stay decorrelated.

* refactor(weights): rename for naming coherence

- Rename _make_default_split_datasets to _make_train_test_split_datasets
  to restore the case-key/method-name grep-line in train()'s match
  dispatch.
- Rename label_weight_column to label_weight_column_name across Utils.py,
  QuickAdapterV3.py and QuickAdapterRegressorV3.py: the helper returns
  a column-name string, not a column accessor; the new name matches the
  *_COLUMN constant convention used elsewhere in Utils.py.
- Drop redundant 'dk.data_dictionary = dd' in _apply_pipelines:
  build_data_dictionary already self-assigns the dict on dk and dd is
  the same object reference.

* style(weights): group EXTREMA_* constants and separate LABEL_* declarations

* docs(weights): rewrite _make_train_test_split_datasets docstring without history narration

Replace the deviation list and PR-history reference with a concise
description of what the function IS (sklearn-key whitelist, honored
tunables, weight propagation contract).

* fix(weights): resolve KeyError in label_weighting config consumption

_compose_per_row_weights passed self.config (root) to
get_label_weighting_config and accessed weighting_config['aggregation']
directly; the helper expects freqai.label_weighting and returns
{default, columns}. Fix consumes self.freqai_info['label_weighting']
and reads ['default']['aggregation'] / ['default']['softmax_temperature'].

* fix(weights): honor drop_mask in sanitize_and_renormalize fallback

When total <= 0 or non-finite, the helper returned np.ones_like(arr)
ignoring drop_mask, resurrecting dropped rows with weight=1. Fallback
now zeros drop_mask rows before returning.

* fix(config): rename reverse_test_train_order to reverse_train_test_order

Match the canonical key name from upstream freqtrade and from the code
in QuickAdapterRegressorV3._make_train_test_split_datasets and
_make_timeseries_split_datasets. The previous template key was silently
ignored.

* fix(strategy): clip smoothed weight column to non-negative finite

Some smoothing methods (savgol, filtfilt) can ring negative on positive
input. Clip the smoothed weight series to >= 0 and replace non-finite
with 0 before assigning to the dataframe so compose_sample_weights does
not silently drop rows that were positive before smoothing.

* fix(weights): apply project _TEST_SIZE default when data_split_parameters omits test_size

The whitelist comprehension that builds sklearn_kwargs only preserved keys
present in data_split_parameters; the local test_size variable computed via
dsp.get(..., _TEST_SIZE) was never injected back. Configs without an explicit
test_size silently fell through to sklearn's stock 0.25 default instead of
the project's 0.1.

Replace bare 'shuffle' insertion with setdefault for both shuffle and
test_size so sklearn_kwargs always carries the project defaults.
Update _make_train_test_split_datasets docstring to reflect the actual
default behavior.

* refactor(strategy): rename smoothed_weights to smoothed_label_weights

Aligns naming with surrounding label_weights variable, EXTREMA_WEIGHT_SMOOTHED_COLUMN
constant and compute_label_weights helper.

* feat(weights): split per-row aggregation into freqai.sample_weighting block

Cross-metric (per-pivot) and cross-label (per-row) compositions are
distinct distributions. Decouple their tunables:

- freqai.label_weighting.{aggregation,softmax_temperature} stay for
  cross-metric aggregation in compute_label_weights (combined strategy).
- New freqai.sample_weighting.{aggregation,softmax_temperature} for
  cross-label composition in
  QuickAdapterRegressorV3._compose_per_row_weights.

Add _SAMPLE_WEIGHTING_SPECS, DEFAULTS_SAMPLE_WEIGHTING and
get_sample_weighting_config helper routed through _get_label_config so
the returned shape ({default, columns}) matches the get_label_*_config
family.

* refactor(weights): pass logger as parameter and log per-label weight column status

Drop the module-level _logger introduced in Utils.py; compose_sample_weights
now takes a keyword-only logger argument, matching the caller-passes-logger
pattern used by every other helper in the file.

In _compose_per_row_weights, log per-label weight column resolution at
debug when columns are present (static across retrains) and at warning
when none are found (unexpected configuration; falls back to temporal
weights only).

* fix(weights): use shape-consistent empty containers for test_size=0 sentinels

Replace np.zeros(2)/pd.DataFrame() sentinels with iloc[:0] / weights[:0]
slices so test_features, test_labels and test_weights all have 0 rows
with preserved column names and dtype. Behavior unchanged because
_apply_pipelines skips test-side processing when test_size == 0, but
shape-consistent containers respect the declared types and avoid
surprising downstream consumers.

quickadapter/user_data/config-template.json
quickadapter/user_data/freqaimodels/QuickAdapterRegressorV3.py
quickadapter/user_data/strategies/LabelTransformer.py
quickadapter/user_data/strategies/QuickAdapterV3.py
quickadapter/user_data/strategies/Utils.py

index 0cfff0aa3e6fe53b6498a4d9892a82360682b8e7..08aa8ee4dd11e9e1be1d0eb092b1c9a0a4450f08 100644 (file)
       //   }
       // }
     },
+    "sample_weighting": {
+      "aggregation": "arithmetic_mean",
+      "softmax_temperature": 1.0
+    },
     "label_smoothing": {
       "method": "kaiser",
       "window_candles": 5,
       "indicator_periods_candles": [8, 16, 32],
       "inlier_metric_window": 0,
       "noise_standard_deviation": 0.02,
-      "reverse_test_train_order": false,
+      "reverse_train_test_order": false,
       "plot_feature_importances": 0,
       "buffer_train_data_candles": 100
     },
index a9aebce4fa6fe98381ebcca6118832970bfddfd8..60d713faf9a073673fcb04a63d8a204d955b0e65 100644 (file)
@@ -3,6 +3,7 @@ import logging
 import random
 import time
 import warnings
+from collections import Counter
 from functools import lru_cache
 from pathlib import Path
 from typing import AbstractSet, Any, Callable, Final, Literal, Optional, Union, cast
@@ -21,7 +22,7 @@ from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel
 from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
 from numpy.typing import NDArray
 from optuna.study.study import ObjectiveFuncType
-from sklearn.model_selection import TimeSeriesSplit
+from sklearn.model_selection import TimeSeriesSplit, train_test_split
 from sklearn.preprocessing import (
     MaxAbsScaler,
     MinMaxScaler,
@@ -50,19 +51,23 @@ from Utils import (
     LABEL_COLUMNS,
     REGRESSORS,
     Regressor,
+    compose_sample_weights,
     ensure_datetime_series,
-    eval_set_and_weights,
+    make_test_set_and_weights,
     fit_regressor,
     format_dict,
     format_number,
     get_label_defaults,
     get_label_pipeline_config,
     get_label_prediction_config,
+    get_sample_weighting_config,
     get_min_max_label_period_candles,
     get_optuna_study_model_parameters,
+    label_weight_column_name,
     migrate_config,
     optuna_load_best_params,
     optuna_save_best_params,
+    sanitize_and_renormalize,
     soft_extremum,
     zigzag,
 )
@@ -76,6 +81,7 @@ ClusterMethod = Literal["kmeans", "kmeans2", "kmedoids"]
 DensityMethod = Literal["knn", "medoid"]
 SelectionMethod = Union[DistanceMethod, ClusterMethod, DensityMethod]
 ValidationMode = Literal["warn", "raise", "none"]
+SplitFn = Callable[[pd.DataFrame, pd.DataFrame, NDArray[np.floating]], dict[str, Any]]
 warnings.simplefilter(action="ignore", category=FutureWarning)
 
 logger = logging.getLogger(__name__)
@@ -98,10 +104,14 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
     https://github.com/sponsors/robcaulk
     """
 
-    version = "3.11.8"
+    version = "3.11.9"
 
     _TEST_SIZE: Final[float] = 0.1
 
+    _SKLEARN_TRAIN_TEST_SPLIT_KEYS: Final[frozenset[str]] = frozenset(
+        {"test_size", "train_size", "random_state", "shuffle", "stratify"}
+    )
+
     _SQRT_2: Final[float] = np.sqrt(2.0)
 
     _OPTUNA_LABEL_N_OBJECTIVES: Final[int] = 7
@@ -318,9 +328,36 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
         return set(QuickAdapterRegressorV3._POWER_MEAN_MAP.keys())
 
     @staticmethod
-    @lru_cache(maxsize=None)
-    def _data_split_methods_set() -> set[str]:
-        return set(QuickAdapterRegressorV3._DATA_SPLIT_METHODS)
+    def _shuffle_in_unison(
+        features: pd.DataFrame,
+        labels: pd.DataFrame,
+        weights: NDArray[np.floating],
+        seed: int,
+    ) -> tuple[pd.DataFrame, pd.DataFrame, NDArray[np.floating]]:
+        features = features.sample(frac=1, random_state=seed).reset_index(drop=True)
+        labels = labels.sample(frac=1, random_state=seed).reset_index(drop=True)
+        weights = (
+            pd.DataFrame(weights)
+            .sample(frac=1, random_state=seed)
+            .reset_index(drop=True)
+            .to_numpy()[:, 0]
+        )
+        return features, labels, weights
+
+    @staticmethod
+    def _coerce_int(value: Any, name: str, *, minimum: int) -> int:
+        if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
+            raise ValueError(
+                f"Invalid data_split_parameters.{name} value {value!r}: "
+                f"must be int >= {minimum}"
+            )
+        return value
+
+    @staticmethod
+    def _coerce_optional_int(value: Any, name: str, *, minimum: int) -> Optional[int]:
+        if value is None:
+            return None
+        return QuickAdapterRegressorV3._coerce_int(value, name, minimum=minimum)
 
     @staticmethod
     def _get_selection_category(method: str) -> Optional[str]:
@@ -1334,85 +1371,269 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
     def train(
         self, unfiltered_df: pd.DataFrame, pair: str, dk: FreqaiDataKitchen, **kwargs
     ) -> Any:
-        """
-        Filter the training data and train a model to it.
-
-        Supports two data split methods:
-        - 'train_test_split' (default): Delegates to BaseRegressionModel.train()
-        - 'timeseries_split': Chronological split with configurable gap. Uses the final
-          fold from sklearn's TimeSeriesSplit.
-
-        :param unfiltered_df: Full dataframe for the current training period
-        :param pair: Trading pair being trained
-        :param dk: FreqaiDataKitchen object containing configuration
-        :return: Trained model
+        """Train a model with per-row sample weights.
+
+        Dispatches on ``data_split_parameters.method``:
+        - ``train_test_split``: random sklearn split.
+        - ``timeseries_split``: chronological final-fold split.
+        Both paths compose per-row weights via ``_compose_per_row_weights``
+        before splitting and feed them to ``model.fit(sample_weight=...)``
+        through ``_train_common``. Train and test weights are renormalized
+        to mean=1 after ``feature_pipeline.fit_transform`` to preserve the
+        invariant despite pipeline-level row drops.
         """
         method = self.data_split_parameters.get(
             "method", QuickAdapterRegressorV3.DATA_SPLIT_METHOD_DEFAULT
         )
 
-        if method not in QuickAdapterRegressorV3._data_split_methods_set():
+        match method:
+            case "train_test_split":
+                split_builder = self._make_train_test_split_datasets
+            case "timeseries_split":
+                split_builder = self._make_timeseries_split_datasets
+            case _:
+                raise ValueError(
+                    f"Invalid data_split_parameters.method value {method!r}: "
+                    f"supported values are "
+                    f"{', '.join(QuickAdapterRegressorV3._DATA_SPLIT_METHODS)}"
+                )
+
+        def split_fn(
+            features: pd.DataFrame,
+            labels: pd.DataFrame,
+            weights: NDArray[np.floating],
+        ) -> dict[str, Any]:
+            return split_builder(features, labels, weights, dk)
+
+        weight_col_counts = Counter(
+            label_weight_column_name(label) for label in dk.label_list
+        )
+        duplicates = {col: n for col, n in weight_col_counts.items() if n > 1}
+        if duplicates:
             raise ValueError(
-                f"Invalid data_split_parameters.method value {method!r}: "
-                f"supported values are {', '.join(QuickAdapterRegressorV3._DATA_SPLIT_METHODS)}"
+                f"Duplicate weight column names {duplicates!r} from labels "
+                f"{dk.label_list}: each label must produce a unique weight_column_name"
             )
 
         logger.info(f"Using data split method: {method}")
+        return self._train_common(unfiltered_df, pair, dk, split_fn, **kwargs)
 
-        if method == QuickAdapterRegressorV3.DATA_SPLIT_METHOD_DEFAULT:
-            return super().train(unfiltered_df, pair, dk, **kwargs)
-
-        elif (
-            method == QuickAdapterRegressorV3._DATA_SPLIT_METHODS[1]
-        ):  # timeseries_split
-            logger.info(
-                f"-------------------- Starting training {pair} --------------------"
+    def _make_train_test_split_datasets(
+        self,
+        features: pd.DataFrame,
+        labels: pd.DataFrame,
+        weights: NDArray[np.floating],
+        dk: FreqaiDataKitchen,
+    ) -> dict[str, Any]:
+        """Train/test split via sklearn's ``train_test_split``.
+
+        Routes ``data_split_parameters`` to sklearn through a whitelist of
+        sklearn-recognized keys; project-custom keys (``method``,
+        ``n_splits``, ``gap``, ``max_train_size``) are filtered out.
+        ``shuffle`` and ``test_size`` default to ``False`` and ``_TEST_SIZE``
+        respectively when absent from ``data_split_parameters``. Honors
+        ``feature_parameters.shuffle_after_split`` (deterministic when
+        ``random_state`` is set) and ``feature_parameters.reverse_train_test_order``.
+        Per-row sample weights are sliced positionally and propagate to both
+        train and test sets.
+        """
+        feat_dict = self.freqai_info.get("feature_parameters", {})
+        dsp = dict(self.data_split_parameters)
+        dsp.setdefault("shuffle", False)
+        dsp.setdefault("test_size", QuickAdapterRegressorV3._TEST_SIZE)
+        sklearn_kwargs = {
+            k: v
+            for k, v in dsp.items()
+            if k in QuickAdapterRegressorV3._SKLEARN_TRAIN_TEST_SPLIT_KEYS
+        }
+        test_size = dsp["test_size"]
+        if isinstance(test_size, bool) or not isinstance(test_size, (int, float)):
+            raise ValueError(
+                f"Invalid data_split_parameters.test_size value {test_size!r}: "
+                f"must be int or float"
+            )
+
+        if test_size != 0:
+            (
+                train_features,
+                test_features,
+                train_labels,
+                test_labels,
+                train_weights,
+                test_weights,
+            ) = train_test_split(features, labels, weights, **sklearn_kwargs)
+        else:
+            train_features = features
+            train_labels = labels
+            train_weights = weights
+            test_features = features.iloc[:0]
+            test_labels = labels.iloc[:0]
+            test_weights = weights[:0]
+
+        if feat_dict.get("shuffle_after_split", False):
+            parent_seed = sklearn_kwargs.get("random_state")
+            shuffle_rng = (
+                random.Random(parent_seed)
+                if parent_seed is not None
+                else random.Random()
+            )
+            train_features, train_labels, train_weights = (
+                QuickAdapterRegressorV3._shuffle_in_unison(
+                    train_features,
+                    train_labels,
+                    train_weights,
+                    shuffle_rng.randint(0, 2**31 - 1),
+                )
             )
+            if test_size != 0:
+                test_features, test_labels, test_weights = (
+                    QuickAdapterRegressorV3._shuffle_in_unison(
+                        test_features,
+                        test_labels,
+                        test_weights,
+                        shuffle_rng.randint(0, 2**31 - 1),
+                    )
+                )
 
-            start_time = time.time()
+        train_weights = sanitize_and_renormalize(train_weights)
+        if test_size != 0:
+            test_weights = sanitize_and_renormalize(test_weights)
 
-            features_filtered, labels_filtered = dk.filter_features(
-                unfiltered_df,
-                dk.training_features_list,
-                dk.label_list,
-                training_filter=True,
+        if feat_dict.get("reverse_train_test_order", False):
+            return dk.build_data_dictionary(
+                test_features,
+                train_features,
+                test_labels,
+                train_labels,
+                test_weights,
+                train_weights,
             )
+        return dk.build_data_dictionary(
+            train_features,
+            test_features,
+            train_labels,
+            test_labels,
+            train_weights,
+            test_weights,
+        )
 
-            dates = ensure_datetime_series(unfiltered_df["date"])
-            start_date = dates.iloc[0].strftime("%Y-%m-%d")
-            end_date = dates.iloc[-1].strftime("%Y-%m-%d")
-            logger.info(
-                f"-------------------- Training on data from {start_date} to "
-                f"{end_date} --------------------"
+    def _compose_per_row_weights(
+        self,
+        features_filtered: pd.DataFrame,
+        unfiltered_df: pd.DataFrame,
+        dk: FreqaiDataKitchen,
+    ) -> NDArray[np.floating]:
+        """Build a per-row sample weight vector aligned to features_filtered.index.
+
+        Composes freqtrade's temporal recency weight with the configured
+        per-label aggregation (default ``arithmetic_mean``) of every
+        per-target weight column present on ``unfiltered_df``. Alignment
+        runs before any shuffle/split on ``features_filtered.index``
+        (a subset of ``unfiltered_df.index``) to avoid post-hoc reindex
+        against shuffled data. Iterates ``dk.label_list`` and only includes
+        labels whose ``label_weight_column_name(label)`` exists on
+        ``unfiltered_df``.
+        """
+        if not unfiltered_df.index.is_unique:
+            raise ValueError(
+                "unfiltered_df.index must be unique for label-based weight "
+                "alignment; received non-unique index"
             )
-
-            dd = self._make_timeseries_split_datasets(
-                features_filtered, labels_filtered, dk
+        if not features_filtered.index.isin(unfiltered_df.index).all():
+            raise ValueError(
+                "features_filtered.index must be a subset of "
+                "unfiltered_df.index (filter_features should preserve original "
+                "row labels)"
             )
-
-            if (
-                not self.freqai_info.get("fit_live_predictions_candles", 0)
-                or not self.live
-            ):
-                dk.fit_labels()
-
-            dd = self._apply_pipelines(dd, dk, pair)
-
-            logger.info(
-                f"Training model on {len(dd['train_features'].columns)} features"
+        n_rows = len(features_filtered)
+        feat_dict = self.freqai_info.get("feature_parameters", {})
+        weight_factor = feat_dict.get("weight_factor", 0)
+        if (
+            not isinstance(weight_factor, bool)
+            and isinstance(weight_factor, (int, float))
+            and weight_factor > 0
+        ):
+            temporal = np.asarray(dk.set_weights_higher_recent(n_rows), dtype=float)
+        else:
+            temporal = np.ones(n_rows, dtype=float)
+
+        per_label: dict[str, NDArray[np.floating]] = {}
+        missing: list[str] = []
+        for label in dk.label_list:
+            col = label_weight_column_name(label)
+            if col in unfiltered_df.columns:
+                per_label[label] = unfiltered_df.loc[
+                    features_filtered.index, col
+                ].to_numpy(dtype=float)
+            else:
+                missing.append(col)
+        if per_label:
+            logger.debug(
+                f"per-label weight columns active: {sorted(per_label)}"
+                + (f" (no weight column for: {sorted(missing)})" if missing else "")
             )
-            logger.info(f"Training model on {len(dd['train_features'])} data points")
-
-            model = self.fit(dd, dk, **kwargs)
-
-            end_time = time.time()
-
-            logger.info(
-                f"-------------------- Done training {pair} "
-                f"({end_time - start_time:.2f} secs) --------------------"
+        else:
+            logger.warning(
+                f"no per-label weight columns found (expected: {sorted(missing)}); "
+                f"falling back to temporal weights only"
             )
+        sample_weighting = get_sample_weighting_config(
+            self.freqai_info.get("sample_weighting", {}), logger
+        )
+        sample_weighting_default = sample_weighting["default"]
+        return compose_sample_weights(
+            temporal,
+            per_label,
+            logger=logger,
+            aggregation=sample_weighting_default["aggregation"],
+            softmax_temperature=sample_weighting_default["softmax_temperature"],
+        )
 
-            return model
+    def _train_common(
+        self,
+        unfiltered_df: pd.DataFrame,
+        pair: str,
+        dk: FreqaiDataKitchen,
+        split_fn: SplitFn,
+        **kwargs,
+    ) -> Any:
+        logger.info(
+            f"-------------------- Starting training {pair} --------------------"
+        )
+        start_time = time.time()
+        features_filtered, labels_filtered = dk.filter_features(
+            unfiltered_df,
+            dk.training_features_list,
+            dk.label_list,
+            training_filter=True,
+        )
+        weights = self._compose_per_row_weights(features_filtered, unfiltered_df, dk)
+        dates = ensure_datetime_series(unfiltered_df["date"])
+        start_date = dates.iloc[0].strftime("%Y-%m-%d")
+        end_date = dates.iloc[-1].strftime("%Y-%m-%d")
+        logger.info(
+            f"-------------------- Training on data from {start_date} to "
+            f"{end_date} --------------------"
+        )
+        dd = split_fn(features_filtered, labels_filtered, weights)
+        if not self.freqai_info.get("fit_live_predictions_candles", 0) or not self.live:
+            dk.fit_labels()
+        dd = self._apply_pipelines(dd, dk, pair)
+        if len(dd["train_features"]) != len(dd["train_weights"]):
+            raise RuntimeError(
+                f"Pipeline broke shape invariant: "
+                f"len(train_features)={len(dd['train_features'])} != "
+                f"len(train_weights)={len(dd['train_weights'])}"
+            )
+        logger.info(f"Training model on {len(dd['train_features'].columns)} features")
+        logger.info(f"Training model on {len(dd['train_features'])} data points")
+        model = self.fit(dd, dk, **kwargs)
+        end_time = time.time()
+        logger.info(
+            f"-------------------- Done training {pair} "
+            f"({end_time - start_time:.2f} secs) --------------------"
+        )
+        return model
 
     def _apply_pipelines(
         self,
@@ -1439,6 +1660,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                 dd["train_features"], dd["train_labels"], dd["train_weights"]
             )
         )
+        dd["train_weights"] = sanitize_and_renormalize(dd["train_weights"])
 
         dd["train_labels"], _, _ = dk.label_pipeline.fit_transform(dd["train_labels"])
 
@@ -1488,16 +1710,16 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                         dd["test_features"], dd["test_labels"], dd["test_weights"]
                     )
                 )
+                dd["test_weights"] = sanitize_and_renormalize(dd["test_weights"])
                 dd["test_labels"], _, _ = dk.label_pipeline.transform(dd["test_labels"])
 
-        dk.data_dictionary = dd
-
         return dd
 
     def _make_timeseries_split_datasets(
         self,
         filtered_dataframe: pd.DataFrame,
         labels: pd.DataFrame,
+        weights: NDArray[np.floating],
         dk: FreqaiDataKitchen,
     ) -> dict:
         """
@@ -1509,43 +1731,55 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
 
         :param filtered_dataframe: Feature data to split
         :param labels: Label data to split
-        :param dk: FreqaiDataKitchen instance for weight calculation and data building
+        :param weights: Pre-computed per-row sample weights aligned to
+                        filtered_dataframe rows by position; sliced via
+                        ``weights[train_idx]`` / ``weights[test_idx]``.
+        :param dk: FreqaiDataKitchen instance for data building
         :return: data_dictionary with train/test features/labels/weights
         """
-        n_splits = int(
+        feat_dict = self.freqai_info.get("feature_parameters", {})
+        if feat_dict.get("shuffle_after_split", False):
+            raise ValueError(
+                "feature_parameters.shuffle_after_split=True is incompatible "
+                "with data_split_parameters.method='timeseries_split': "
+                "chronological split must preserve temporal ordering"
+            )
+        n_splits = QuickAdapterRegressorV3._coerce_int(
             self.data_split_parameters.get(
                 "n_splits", QuickAdapterRegressorV3.TIMESERIES_N_SPLITS_DEFAULT
-            )
+            ),
+            "n_splits",
+            minimum=2,
         )
-        gap = int(
+        gap = QuickAdapterRegressorV3._coerce_int(
             self.data_split_parameters.get(
                 "gap", QuickAdapterRegressorV3.TIMESERIES_GAP_DEFAULT
-            )
+            ),
+            "gap",
+            minimum=0,
         )
-        max_train_size = self.data_split_parameters.get(
-            "max_train_size", QuickAdapterRegressorV3.TIMESERIES_MAX_TRAIN_SIZE_DEFAULT
+        max_train_size = QuickAdapterRegressorV3._coerce_optional_int(
+            self.data_split_parameters.get(
+                "max_train_size",
+                QuickAdapterRegressorV3.TIMESERIES_MAX_TRAIN_SIZE_DEFAULT,
+            ),
+            "max_train_size",
+            minimum=1,
         )
-        max_train_size = int(max_train_size) if max_train_size is not None else None
-
-        if n_splits < 2:
-            raise ValueError(
-                f"Invalid data_split_parameters.n_splits value {n_splits!r}: must be >= 2"
-            )
-        if gap < 0:
-            raise ValueError(
-                f"Invalid data_split_parameters.gap value {gap!r}: must be >= 0"
-            )
-        if max_train_size is not None and max_train_size < 1:
-            raise ValueError(
-                f"Invalid data_split_parameters.max_train_size value {max_train_size!r}: "
-                f"must be >= 1 or None"
-            )
 
         test_size = self.data_split_parameters.get("test_size", None)
         if test_size is not None:
-            if isinstance(test_size, float) and 0 < test_size < 1:
+            if (
+                not isinstance(test_size, bool)
+                and isinstance(test_size, float)
+                and 0 < test_size < 1
+            ):
                 test_size = int(len(filtered_dataframe) * test_size)
-            elif isinstance(test_size, int) and test_size >= 1:
+            elif (
+                not isinstance(test_size, bool)
+                and isinstance(test_size, int)
+                and test_size >= 1
+            ):
                 pass
             else:
                 raise ValueError(
@@ -1573,25 +1807,31 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
             max_train_size=max_train_size,
             test_size=test_size,
         )
-        train_idx: np.ndarray = np.array([])
-        test_idx: np.ndarray = np.array([])
-        for train_idx, test_idx in tscv.split(filtered_dataframe):
-            pass
+        folds = list(tscv.split(filtered_dataframe))
+        if not folds:
+            raise ValueError(
+                f"TimeSeriesSplit yielded no folds for {len(filtered_dataframe)} "
+                f"samples (n_splits={n_splits}, gap={gap}, "
+                f"max_train_size={max_train_size}, test_size={test_size})"
+            )
+        train_idx, test_idx = folds[-1]
 
         train_features = filtered_dataframe.iloc[train_idx]
         test_features = filtered_dataframe.iloc[test_idx]
         train_labels = labels.iloc[train_idx]
         test_labels = labels.iloc[test_idx]
-
-        feature_parameters = self.freqai_info.get("feature_parameters", {})
-        if feature_parameters.get("weight_factor", 0) > 0:
-            total_weights = dk.set_weights_higher_recent(len(train_idx) + len(test_idx))
-            train_weights = total_weights[: len(train_idx)]
-            test_weights = total_weights[len(train_idx) :]
-        else:
-            train_weights = np.ones(len(train_idx))
-            test_weights = np.ones(len(test_idx))
-
+        train_weights = sanitize_and_renormalize(weights[train_idx])
+        test_weights = sanitize_and_renormalize(weights[test_idx])
+
+        if feat_dict.get("reverse_train_test_order", False):
+            return dk.build_data_dictionary(
+                test_features,
+                train_features,
+                test_labels,
+                train_labels,
+                test_weights,
+                train_weights,
+            )
         return dk.build_data_dictionary(
             train_features,
             test_features,
@@ -1664,7 +1904,7 @@ class QuickAdapterRegressorV3(BaseRegressionModel):
                     **optuna_hp_params,
                 }
 
-        eval_set, eval_weights = eval_set_and_weights(
+        eval_set, eval_weights = make_test_set_and_weights(
             X_test,
             y_test,
             test_weights,
@@ -3512,7 +3752,7 @@ def hp_objective(
     )
     model_training_parameters = {**model_training_parameters, **study_model_parameters}
 
-    eval_set, eval_weights = eval_set_and_weights(
+    eval_set, eval_weights = make_test_set_and_weights(
         X_test, y_test, test_weights, test_size
     )
 
index 11f93b8e9b6de4ff6a03a4824143f20121578be3..ca658d7cbb720e74b4703b7cbb8ff31c82e4d483 100644 (file)
@@ -87,6 +87,11 @@ DEFAULTS_LABEL_WEIGHTING: Final[dict[str, Any]] = {
     "softmax_temperature": 1.0,
 }
 
+DEFAULTS_SAMPLE_WEIGHTING: Final[dict[str, Any]] = {
+    "aggregation": COMBINED_AGGREGATIONS[0],  # "arithmetic_mean"
+    "softmax_temperature": 1.0,
+}
+
 DEFAULTS_LABEL_PIPELINE: Final[dict[str, Any]] = {
     "standardization": STANDARDIZATION_TYPES[0],  # "none"
     "robust_quantiles": (0.25, 0.75),
index f02eba277468075aa70477618f7bedf9b98d8744..e839555ad1c702696a1fe9426447b6b5002ad56a 100644 (file)
@@ -29,15 +29,16 @@ from technical.pivots_points import pivots_points
 from Utils import (
     DEFAULT_FIT_LIVE_PREDICTIONS_CANDLES,
     EXTREMA_COLUMN,
+    EXTREMA_DIRECTION_COLUMN,
+    EXTREMA_DIRECTION_SMOOTHED_COLUMN,
+    EXTREMA_WEIGHT_COLUMN,
+    EXTREMA_WEIGHT_SMOOTHED_COLUMN,
     LABEL_COLUMNS,
-    MAXIMA_COLUMN,
-    MINIMA_COLUMN,
-    SMOOTHED_EXTREMA_COLUMN,
     TRADE_PRICE_TARGETS,
     alligator,
-    apply_label_weighting,
     bottom_log_return,
     calculate_quantile,
+    compute_label_weights,
     ensure_datetime_series,
     ewo,
     format_dict,
@@ -49,12 +50,13 @@ from Utils import (
     get_label_smoothing_config,
     get_label_weighting_config,
     get_zl_ma_fn,
+    label_weight_column_name,
     migrate_config,
     nan_average,
     non_zero_diff,
     optuna_load_best_params,
     price_retracement_percent,
-    smooth_label,
+    smooth,
     top_log_return,
     validate_range,
     vwapb,
@@ -106,10 +108,8 @@ class QuickAdapterV3(IStrategy):
 
     _ANNOTATION_LINE_OFFSET_CANDLES: Final[int] = 10
 
-    _PLOT_EXTREMA_MIN_EPS: Final[float] = 0.01
-
     def version(self) -> str:
-        return "3.11.8"
+        return "3.11.9"
 
     timeframe = "5m"
     timeframe_minutes = timeframe_to_minutes(timeframe)
@@ -205,10 +205,19 @@ class QuickAdapterV3(IStrategy):
                     },
                     EXTREMA_COLUMN: {"color": "orange", "type": "line"},
                 },
-                "min_max": {
-                    SMOOTHED_EXTREMA_COLUMN: {"color": "wheat", "type": "line"},
-                    MAXIMA_COLUMN: {"color": "red", "type": "bar"},
-                    MINIMA_COLUMN: {"color": "green", "type": "bar"},
+                "direction": {
+                    EXTREMA_DIRECTION_COLUMN: {"color": "wheat", "type": "line"},
+                    EXTREMA_DIRECTION_SMOOTHED_COLUMN: {
+                        "color": "orange",
+                        "type": "line",
+                    },
+                },
+                "weight": {
+                    EXTREMA_WEIGHT_COLUMN: {"color": "wheat", "type": "line"},
+                    EXTREMA_WEIGHT_SMOOTHED_COLUMN: {
+                        "color": "orange",
+                        "type": "line",
+                    },
                 },
             },
         }
@@ -826,57 +835,37 @@ class QuickAdapterV3(IStrategy):
                 label_col, label_weighting["default"], label_weighting["columns"]
             )
 
-            weighted_label, _ = apply_label_weighting(
-                label=label_data.series,
+            label_weights = compute_label_weights(
+                n_values=len(label_data.series),
                 indices=label_data.indices,
                 metrics=label_data.metrics,
                 weighting_config=col_weighting_config,
             )
 
-            dataframe[label_col] = weighted_label
+            label_weight_col = label_weight_column_name(label_col)
+
+            dataframe[label_col] = label_data.series
+            dataframe[label_weight_col] = label_weights
 
             if label_col == EXTREMA_COLUMN:
-                extrema = dataframe[label_col]
-                extrema_direction = label_data.series
-                plot_eps = extrema.abs().where(extrema.ne(0.0)).min()
-                if not np.isfinite(plot_eps):
-                    plot_eps = 0.0
-                plot_eps = max(
-                    float(plot_eps) * 0.5, QuickAdapterV3._PLOT_EXTREMA_MIN_EPS
-                )
-                dataframe[MAXIMA_COLUMN] = (
-                    extrema.where(extrema_direction.gt(0), 0.0)
-                    .clip(lower=0.0)
-                    .mask(
-                        extrema_direction.gt(0) & extrema.eq(0.0),
-                        plot_eps,
-                    )
-                )
-                dataframe[MINIMA_COLUMN] = (
-                    extrema.where(extrema_direction.lt(0), 0.0)
-                    .clip(upper=0.0)
-                    .mask(
-                        extrema_direction.lt(0) & extrema.eq(0.0),
-                        -plot_eps,
-                    )
-                )
+                dataframe[EXTREMA_DIRECTION_COLUMN] = dataframe[label_col]
+                dataframe[EXTREMA_WEIGHT_COLUMN] = dataframe[label_weight_col]
 
             col_smoothing_config = get_label_column_config(
                 label_col, label_smoothing["default"], label_smoothing["columns"]
             )
 
-            dataframe[label_col] = smooth_label(
-                dataframe[label_col],
-                col_smoothing_config["method"],
-                col_smoothing_config["window_candles"],
-                col_smoothing_config["beta"],
-                col_smoothing_config["polyorder"],
-                col_smoothing_config["mode"],
-                col_smoothing_config["sigma"],
+            dataframe[label_col] = smooth(dataframe[label_col], **col_smoothing_config)
+            smoothed_label_weights = smooth(
+                dataframe[label_weight_col], **col_smoothing_config
+            )
+            dataframe[label_weight_col] = smoothed_label_weights.where(
+                smoothed_label_weights.gt(0) & smoothed_label_weights.notna(), 0.0
             )
 
             if label_col == EXTREMA_COLUMN:
-                dataframe[SMOOTHED_EXTREMA_COLUMN] = dataframe[label_col]
+                dataframe[EXTREMA_DIRECTION_SMOOTHED_COLUMN] = dataframe[label_col]
+                dataframe[EXTREMA_WEIGHT_SMOOTHED_COLUMN] = dataframe[label_weight_col]
 
         return dataframe
 
index a1def001e1f331e1d15219f061152338961d1cb1..da542dc25e94e84f848c093cfb10496921eb2a8f 100644 (file)
@@ -3,6 +3,7 @@ import functools
 import hashlib
 import json
 import math
+import re
 from dataclasses import dataclass
 from enum import IntEnum
 from functools import lru_cache, singledispatch
@@ -31,6 +32,7 @@ from LabelTransformer import (
     DEFAULTS_LABEL_PREDICTION,
     DEFAULTS_LABEL_SMOOTHING,
     DEFAULTS_LABEL_WEIGHTING,
+    DEFAULTS_SAMPLE_WEIGHTING,
     EXTREMA_SELECTION_METHODS,
     NORMALIZATION_TYPES,
     PREDICTION_METHODS,
@@ -229,6 +231,13 @@ _SMOOTHING_SPECS: Final[dict[str, _ParamSpec]] = {
     ),
 }
 
+_SAMPLE_WEIGHTING_SPECS: Final[dict[str, _ParamSpec]] = {
+    "aggregation": _ParamSpec(_EnumValidator(COMBINED_AGGREGATIONS)),
+    "softmax_temperature": _ParamSpec(
+        _NumericValidator(min_value=0, min_exclusive=True)
+    ),
+}
+
 _PREDICTION_SPECS: Final[dict[str, _ParamSpec]] = {
     "method": _ParamSpec(_EnumValidator(PREDICTION_METHODS)),
     "selection_method": _ParamSpec(_EnumValidator(EXTREMA_SELECTION_METHODS)),
@@ -250,8 +259,45 @@ _PREDICTION_SPECS: Final[dict[str, _ParamSpec]] = {
 
 
 EXTREMA_COLUMN: Final = "&s-extrema"
+EXTREMA_DIRECTION_COLUMN: Final = "extrema_direction"
+EXTREMA_DIRECTION_SMOOTHED_COLUMN: Final = "extrema_direction_smoothed"
+EXTREMA_WEIGHT_COLUMN: Final = "extrema_weight"
+EXTREMA_WEIGHT_SMOOTHED_COLUMN: Final = "extrema_weight_smoothed"
+
+LABEL_WEIGHT_SUFFIX: Final[str] = "_weight"
+
 LABEL_COLUMNS: Final[tuple[str, ...]] = (EXTREMA_COLUMN,)
 
+_FREQAI_LABEL_SIGIL_PATTERN: Final = re.compile(r"^&-?")
+
+
+@lru_cache(maxsize=16)
+def label_weight_column_name(label_col: str) -> str:
+    """Return the weight column name for a label column.
+
+    Strips the freqtrade label sigil (``&`` and its optional immediate ``-``
+    separator) so the resulting column does NOT collide with
+    ``FreqaiDataKitchen.find_labels`` (which selects columns containing ``&``)
+    nor with ``find_features`` (which selects columns containing ``%``).
+    Preserves the project convention where a leading ``s`` denotes a smoothed
+    target series (e.g. ``&s-extrema``); no ``s`` denotes a raw target.
+    Raises ``ValueError`` if the result still contains ``&`` or ``%``.
+
+    Examples:
+        ``"&s-extrema"``      -> ``"s-extrema_weight"`` (smoothed marker preserved)
+        ``"&-amplitude"``     -> ``"amplitude_weight"`` (raw target)
+        ``"&-time_to_pivot"`` -> ``"time_to_pivot_weight"`` (raw target)
+        ``"&-natr"``          -> ``"natr_weight"`` (raw target)
+    """
+    stripped = _FREQAI_LABEL_SIGIL_PATTERN.sub("", label_col, count=1)
+    result = f"{stripped}{LABEL_WEIGHT_SUFFIX}"
+    if "&" in result or "%" in result:
+        raise ValueError(
+            f"label_weight_column_name produced collision-prone name {result!r} "
+            f"from {label_col!r}; weight columns must not contain '&' or '%'"
+        )
+    return result
+
 
 @dataclass
 class LabelData:
@@ -324,10 +370,6 @@ def generate_label_data(
     return generator(dataframe, params)
 
 
-MAXIMA_COLUMN: Final = "maxima"
-MINIMA_COLUMN: Final = "minima"
-SMOOTHED_EXTREMA_COLUMN: Final = "smoothed_extrema"
-
 SmoothingKernel = Literal["gaussian", "kaiser", "triang"]
 SMOOTHING_KERNELS: Final[tuple[SmoothingKernel, ...]] = (
     "gaussian",
@@ -622,6 +664,29 @@ def get_label_smoothing_config(
     )
 
 
+def _validate_sample_weighting_params(
+    config: dict[str, Any],
+    logger: Logger,
+    config_name: str = "sample_weighting",
+) -> dict[str, Any]:
+    return _validate_params(
+        config, logger, config_name, _SAMPLE_WEIGHTING_SPECS, DEFAULTS_SAMPLE_WEIGHTING
+    )
+
+
+def get_sample_weighting_config(
+    config: dict[str, Any],
+    logger: Logger,
+) -> dict[str, Any]:
+    return _get_label_config(
+        config,
+        logger,
+        "sample_weighting",
+        _validate_sample_weighting_params,
+        DEFAULTS_SAMPLE_WEIGHTING,
+    )
+
+
 def _validate_prediction_params(
     config: dict[str, Any],
     logger: Logger,
@@ -680,6 +745,96 @@ def midpoint(value1: T, value2: T) -> T:
     return (value1 + value2) / 2
 
 
+def sanitize_and_renormalize(
+    arr: NDArray[np.floating],
+    drop_mask: NDArray[np.bool_] | None = None,
+) -> NDArray[np.floating]:
+    arr = np.asarray(arr, dtype=float)
+    if arr.size == 0:
+        return arr
+    safe = np.where(np.isfinite(arr) & (arr > 0), arr, 0.0)
+    if drop_mask is not None:
+        safe = safe.copy()
+        safe[drop_mask] = 0.0
+    total = safe.sum()
+    if total > 0 and np.isfinite(total):
+        return safe * (len(safe) / total)
+    fallback = np.ones_like(arr)
+    if drop_mask is not None:
+        fallback[drop_mask] = 0.0
+    return fallback
+
+
+def compose_sample_weights(
+    base_weights: NDArray[np.floating],
+    label_weights_map: dict[str, NDArray[np.floating]],
+    *,
+    logger: Logger,
+    aggregation: CombinedAggregation = COMBINED_AGGREGATIONS[0],
+    softmax_temperature: float = 1.0,
+) -> NDArray[np.floating]:
+    """Combine base sample weights with per-label importance weights.
+
+    Returns w in R+^N with mean(w) == 1. Per-label arrays are sanitized
+    (non-finite or <= 0 -> row dropped), individually mean-normalized,
+    aggregated row-wise via ``aggregation`` (default arithmetic_mean),
+    multiplied with base_weights, zeroed on dropped rows, and renormalized
+    to mean=1.
+
+    Raises ValueError on shape mismatch or when every row is dropped.
+    Default-weight imputation in compute_label_weights uses full-series
+    median (bounded leakage; see AFML chapter 4).
+    """
+    base_weights = np.asarray(base_weights, dtype=float)
+    if not label_weights_map:
+        return sanitize_and_renormalize(base_weights)
+    n = len(base_weights)
+    for label, label_values in label_weights_map.items():
+        arr = np.asarray(label_values, dtype=float)
+        if arr.shape != (n,):
+            raise ValueError(
+                f"compose_sample_weights: label {label!r} has shape {arr.shape}, "
+                f"expected ({n},)"
+            )
+    normalized_per_label: list[NDArray[np.floating]] = []
+    drop_mask = np.zeros(n, dtype=bool)
+    for label_values in label_weights_map.values():
+        arr = np.asarray(label_values, dtype=float)
+        invalid = ~np.isfinite(arr) | (arr <= 0.0)
+        drop_mask |= invalid
+        arr = np.where(invalid, 1.0, np.maximum(arr, np.finfo(float).tiny))
+        normalized_per_label.append(sanitize_and_renormalize(arr))
+    if drop_mask.all():
+        raise ValueError(
+            f"compose_sample_weights: all rows dropped by per-label zero weights "
+            f"(labels={list(label_weights_map)}); no surviving training samples"
+        )
+    stacked = np.vstack(normalized_per_label)
+    agg = _aggregate_metrics(
+        stacked_metrics=stacked,
+        coefficients=np.ones(stacked.shape[0], dtype=float),
+        aggregation=aggregation,
+        softmax_temperature=softmax_temperature,
+    )
+    combined = base_weights * agg
+    combined[drop_mask] = 0.0
+    combined_sum = combined.sum()
+    if combined_sum > 0 and np.isfinite(combined_sum):
+        ratio = n / combined_sum
+        if np.isfinite(ratio):
+            scaled = combined * ratio
+            if np.all(np.isfinite(scaled)):
+                return scaled
+    logger.warning(
+        "compose_sample_weights: aggregated weights collapsed (labels=%s, "
+        "aggregation=%s, combined_sum=%r); falling back to base weights",
+        list(label_weights_map),
+        aggregation,
+        combined_sum,
+    )
+    return sanitize_and_renormalize(base_weights, drop_mask=drop_mask)
+
+
 def nan_average(
     values: NDArray[np.floating],
     weights: NDArray[np.floating] | None = None,
@@ -771,7 +926,7 @@ def zero_phase_filter(
     return pd.Series(filtered_values, index=series.index)
 
 
-def smooth_label(
+def smooth(
     series: pd.Series,
     method: SmoothingMethod = DEFAULTS_LABEL_SMOOTHING["method"],
     window_candles: int = DEFAULTS_LABEL_SMOOTHING["window_candles"],
@@ -884,7 +1039,7 @@ def _impute_weights(
     return weights
 
 
-def _build_weights_array(
+def _scatter_weights(
     n_values: int,
     indices: list[int],
     weights: NDArray[np.floating],
@@ -961,6 +1116,8 @@ def _aggregate_metrics(
             ]
         )
     elif aggregation == COMBINED_AGGREGATIONS[5]:  # "softmax"
+        # Per-column softmax-weighted convex combination of stacked rows.
+        # T -> 0 collapses to argmax row; T -> +inf collapses to coefficient-weighted mean.
         scaled_metrics = stacked_metrics / softmax_temperature
         softmax_weights = sp.special.softmax(scaled_metrics, axis=0)
         combined_weights = softmax_weights * coefficients[:, np.newaxis]
@@ -1044,7 +1201,7 @@ def compute_label_weights(
         weights=weights,
     )
 
-    return _build_weights_array(
+    return _scatter_weights(
         n_values=n_values,
         indices=indices,
         weights=weights,
@@ -1052,46 +1209,6 @@ def compute_label_weights(
     )
 
 
-def _apply_label_weights(
-    values: NDArray[np.floating], weights: NDArray[np.floating]
-) -> NDArray[np.floating]:
-    if weights.size == 0:
-        return values
-
-    if not np.isfinite(weights).all():
-        return values
-
-    if np.allclose(weights, weights[0]):
-        return values
-
-    if np.allclose(weights, DEFAULT_LABEL_WEIGHT):
-        return values
-
-    return values * weights
-
-
-def apply_label_weighting(
-    label: pd.Series,
-    indices: list[int],
-    metrics: dict[str, list[float]],
-    weighting_config: dict[str, Any],
-) -> tuple[pd.Series, pd.Series]:
-    label_values = label.to_numpy(dtype=float)
-    label_index = label.index
-    n_values = label_values.size
-
-    weights = compute_label_weights(
-        n_values=n_values,
-        indices=indices,
-        metrics=metrics,
-        weighting_config=weighting_config,
-    )
-
-    return pd.Series(
-        _apply_label_weights(label_values, weights), index=label_index
-    ), pd.Series(weights, index=label_index)
-
-
 def get_callable_sha256(fn: Callable[..., Any]) -> str:
     if not callable(fn):
         raise ValueError(f"Invalid fn value {type(fn).__name__!r}: must be callable")
@@ -2534,7 +2651,7 @@ def fit_regressor(
     return model
 
 
-def eval_set_and_weights(
+def make_test_set_and_weights(
     X_test: pd.DataFrame,
     y_test: pd.DataFrame,
     test_weights: NDArray[np.floating],