From 2b7186c079e9fb5912614d665cde824974674bac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20G=C5=82owacz?= Date: Fri, 12 Jun 2026 19:25:46 +0200 Subject: [PATCH 01/12] [#28] feat(pipelines): expose separate checkpoint_path and config_path args to SASRecTrainingPipeline.train() method to let the caller explicitly specify where to save best-performing model checkpoint and where corresponding ImpactConfig YAML snapshot --- src/impact/main.py | 13 +++--- .../pipelines/sasrec_training_pipeline.py | 40 ++++++++++++------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/impact/main.py b/src/impact/main.py index 74b1f6d..b619fc9 100644 --- a/src/impact/main.py +++ b/src/impact/main.py @@ -169,7 +169,8 @@ def train_sasrec( _ = sasrec_training_pipeline.train( split=tensorized_splits, num_items=item_index.num_items, - save_path=sasrec_state_dict_path, + checkpoint_path=sasrec_state_dict_path, + config_path=sasrec_state_dict_path.parent / "config.yaml", ) logger.info("SASRecNBR training completed", path=sasrec_state_dict_path.as_posix()) @@ -421,7 +422,9 @@ def grid_search( tuning_results: list[dict[str, Any]] = [] best_model_path: Path = out_dir / "best_model.pt" + best_config_path: Path = out_dir / "best_config.yaml" current_model_path: Path = out_dir / "current_model.pt" + current_config_path: Path = out_dir / "current_config.yaml" logger.info("Starting hyperparameter tuning (Grid Search)...") if impact_config.dataset.size != DatasetSize.SIZE_50M: @@ -452,7 +455,8 @@ def grid_search( val_ndcg = sasrec_training_pipeline.train( split=tensorized_splits, num_items=item_index.num_items, - save_path=current_model_path, + checkpoint_path=current_model_path, + config_path=current_config_path, ) tuning_results.append({"config_id": i, "val_ndcg": val_ndcg, **params}) @@ -463,8 +467,10 @@ def grid_search( # Remove the so-far best model checkpoint if best_model_path.exists(): best_model_path.unlink() + best_config_path.unlink() # Make the current model a new best one _ = current_model_path.rename(best_model_path) + _ = current_config_path.rename(best_config_path) logger.info( "New best model configuration found and saved", val_ndcg=round(val_ndcg, 4), @@ -482,9 +488,6 @@ def grid_search( with results_path.open("w") as f: json.dump(tuning_results, f, indent=4) - best_config_path = out_dir / "best_impact_config.yaml" - best_config.write_yaml(best_config_path) - logger.info( "Grid search completed", best_ndcg=best_ndcg, diff --git a/src/impact/pipelines/sasrec_training_pipeline.py b/src/impact/pipelines/sasrec_training_pipeline.py index b5cb806..63c7dfa 100644 --- a/src/impact/pipelines/sasrec_training_pipeline.py +++ b/src/impact/pipelines/sasrec_training_pipeline.py @@ -25,8 +25,6 @@ from impact.utils import seed_worker if TYPE_CHECKING: - from pathlib import Path - from impact.config import ImpactConfig from impact.schemas import TensorizedSplit @@ -49,7 +47,8 @@ def train( self, split: TensorizedSplit, num_items: int, - save_path: Path | None = None, + checkpoint_path: Path | None = None, + config_path: Path | None = None, ) -> float: """Initialise and train SASRecNBR with early stopping monitored via NDCG@K. @@ -60,17 +59,29 @@ def train( split: Tensorized train/val/test temporal splits; train is used for training, val for early stopping. num_items: Catalog size, passed to the model and negative sampler. - save_path: Where to write the best-performing model checkpoint. If None, no - checkpoint is saved. + checkpoint_path: Path where the best-performing model checkpoint will be + saved. If None, no checkpoint or configuration snapshot is saved. + config_path: Path where the ImpactConfig YAML snapshot will be saved + alongside the best-performing model checkpoint. If None and + `checkpoint_path` is provided, the config snapshot is written to + `checkpoint_path.parent / "config.yaml"`. Cannot be specified when + `checkpoint_path` is None. Returns: Best NDCG@K (calculated on the validation set) reached before training terminated. """ - if save_path is not None: - if save_path.exists(): - raise ValueError(f"Checkpoint already exists: {save_path}") - save_path.parent.mkdir(parents=True, exist_ok=True) + if config_path is not None and checkpoint_path is None: + raise ValueError( + "config_path can only be specified when checkpoint_path is provided" + ) + + if checkpoint_path is not None: + if checkpoint_path.exists(): + raise ValueError(f"Checkpoint already exists: {checkpoint_path}") + + checkpoint_path.parent.mkdir(parents=True, exist_ok=True) + config_path = config_path or checkpoint_path.parent / "config.yaml" logger.info( "Initializing SASRecNBR training run", @@ -162,9 +173,9 @@ def train( best_ndcg = val_ndcg epochs_without_improvement = 0 logger.info("New best model found", best_ndcg=round(best_ndcg, 4)) - if save_path is not None: - torch.save(model.state_dict(), save_path) - self._config.write_yaml(save_path.parent / "config.yaml") + if checkpoint_path is not None: + torch.save(model.state_dict(), checkpoint_path) + self._config.write_yaml(cast(Path, config_path)) else: epochs_without_improvement += 1 @@ -174,10 +185,11 @@ def train( ) break - if save_path is not None: + if checkpoint_path is not None: logger.info( "SASRecNBR training complete, best-performing model checkpoint saved", - best_model_path=save_path, + best_model_path=checkpoint_path, + impact_config_path=config_path, ) else: logger.info("SASRecNBR training complete") From 4226f96bf417d8d33eb6d5cb2c1ccc7bddc0f2fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20G=C5=82owacz?= Date: Fri, 12 Jun 2026 21:40:45 +0200 Subject: [PATCH 02/12] [#28] feat(evaluation): implement computing per-sample Recall@k and NDCG@k metrics to allow collecting raw, unaggregated metrics (for statistical testing), make the functions computing aggregated metrics (means) simple wrappers over per-sample computations --- src/impact/evaluation/__init__.py | 9 +++- src/impact/evaluation/metrics.py | 74 +++++++++++++++++++++++++------ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/impact/evaluation/__init__.py b/src/impact/evaluation/__init__.py index 60ea3b0..ef97bd1 100644 --- a/src/impact/evaluation/__init__.py +++ b/src/impact/evaluation/__init__.py @@ -1,6 +1,13 @@ -from .metrics import compute_ndcg_at_k, compute_recall_at_k +from .metrics import ( + compute_ndcg_at_k, + compute_per_sample_ndcg_at_k, + compute_per_sample_recall_at_k, + compute_recall_at_k, +) __all__ = [ "compute_ndcg_at_k", + "compute_per_sample_ndcg_at_k", + "compute_per_sample_recall_at_k", "compute_recall_at_k", ] diff --git a/src/impact/evaluation/metrics.py b/src/impact/evaluation/metrics.py index fceb373..274294f 100644 --- a/src/impact/evaluation/metrics.py +++ b/src/impact/evaluation/metrics.py @@ -1,16 +1,24 @@ -"""Batched, fully vectorized NDCG@K and Recall@K metrics for Next-Basket Recommendation. +"""Batched, fully vectorized ranking metrics for Next-Basket Recommendation. -All functions accept a targets tensor where 0 denotes a non-evaluable item (item outside -the training index space). Samples containing no evaluable targets are excluded from the -mean. This is particularly important for repetition-/exploration-focused sub-metrics, +Provides Recall@k and NDCG@k implementations that support both per-sample and +aggregated evaluation. + +Ground-truth target tensors use 0 as the non-evaluable item token (item outside the +training index space). Per-sample metric functions return NaN for samples without any +valid targets. Samples containing no evaluable targets are excluded from the mean in the +aggregated evaluation. This is particularly important for rep-/exp-focused sub-metrics, where a sample may contribute only to one target subset. """ import torch -def compute_recall_at_k(logits: torch.Tensor, targets: torch.Tensor, k: int) -> float: - """Calculate mean Recall@K over all samples with at least one valid target. +def compute_per_sample_recall_at_k( + logits: torch.Tensor, + targets: torch.Tensor, + k: int, +) -> torch.Tensor: + """Calculate per-sample Recall@k. Args: logits: [B, num_items + 1] scores from the model with index 0 masked. @@ -18,11 +26,13 @@ def compute_recall_at_k(logits: torch.Tensor, targets: torch.Tensor, k: int) -> k: Rank cutoff. Returns: - Mean Recall@K across all valid samples. Returns 0.0 if no valid sample exist. + Tensor of shape [B] containing Recall@k for each sample. Samples without at + least one valid target receive NaN. """ valid_mask = (targets != 0).any(dim=-1) # [B] if not valid_mask.any(): - return 0.0 # No valid samples + # No valid samples + return torch.full((logits.shape[0],), torch.nan, device=logits.device) # Extract top-K predicted item indices _, topk_indices = torch.topk(logits, k=k, dim=-1) # [B, k] @@ -43,11 +53,12 @@ def compute_recall_at_k(logits: torch.Tensor, targets: torch.Tensor, k: int) -> # Recall is the number of hits divided by the number of ground truth items recalls = num_hits / target_lengths # [B] - return recalls[valid_mask].mean().item() + recalls = recalls.masked_fill(~valid_mask, torch.nan) + return recalls -def compute_ndcg_at_k(logits: torch.Tensor, targets: torch.Tensor, k: int) -> float: - """Calculate mean NDCG@K over all samples with at least one valid target. +def compute_recall_at_k(logits: torch.Tensor, targets: torch.Tensor, k: int) -> float: + """Calculate mean Recall@k over all samples with at least one valid target. Args: logits: [B, num_items + 1] scores from the model with index 0 masked. @@ -55,11 +66,31 @@ def compute_ndcg_at_k(logits: torch.Tensor, targets: torch.Tensor, k: int) -> fl k: Rank cutoff. Returns: - Mean NDCG@K across all valid samples. Returns 0.0 if no valid sample exist. + Mean Recall@k across all valid samples. + """ + return torch.nanmean(compute_per_sample_recall_at_k(logits, targets, k)).item() + + +def compute_per_sample_ndcg_at_k( + logits: torch.Tensor, + targets: torch.Tensor, + k: int, +) -> torch.Tensor: + """Calculate per-sample NDCG@k. + + Args: + logits: [B, num_items + 1] scores from the model with index 0 masked. + targets: [B, K] ground-truth item indices; 0 denotes a non-evaluable item. + k: Rank cutoff. + + Returns: + Tensor of shape [B] containing NDCG@k for each sample. Samples without at + least one valid target receive NaN. """ valid_mask = (targets != 0).any(dim=-1) # [B] if not valid_mask.any(): - return 0.0 # No valid samples + # No valid samples + return torch.full((logits.shape[0],), torch.nan, device=logits.device) # Extract top-K predicted item indices _, topk_indices = torch.topk(logits, k=k, dim=-1) # [B, k] @@ -90,4 +121,19 @@ def compute_ndcg_at_k(logits: torch.Tensor, targets: torch.Tensor, k: int) -> fl idcg = (ideal_hits * discounts).sum(dim=1).clamp(min=1e-8) # [B] ndcg = dcg / idcg # [B] - return ndcg[valid_mask].mean().item() + ndcg = ndcg.masked_fill(~valid_mask, torch.nan) + return ndcg + + +def compute_ndcg_at_k(logits: torch.Tensor, targets: torch.Tensor, k: int) -> float: + """Calculate mean NDCG@k over all samples with at least one valid target. + + Args: + logits: [B, num_items + 1] scores from the model with index 0 masked. + targets: [B, K] ground-truth item indices; 0 denotes a non-evaluable item. + k: Rank cutoff. + + Returns: + Mean NDCG@k across all valid samples. + """ + return torch.nanmean(compute_per_sample_ndcg_at_k(logits, targets, k)).item() From a14893fc4e63fec00689475bbda3a8c512c43b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20G=C5=82owacz?= Date: Fri, 12 Jun 2026 22:13:56 +0200 Subject: [PATCH 03/12] [#28] feat(pipelines): update _compute_batch_metrics() method of the eval pipeline to calculate both batch-level metric sums (unnormalized) and raw per-sample metric values for statistical testing --- .../pipelines/impact_evaluation_pipeline.py | 103 ++++++++++++------ 1 file changed, 71 insertions(+), 32 deletions(-) diff --git a/src/impact/pipelines/impact_evaluation_pipeline.py b/src/impact/pipelines/impact_evaluation_pipeline.py index 68702d8..d8e351b 100644 --- a/src/impact/pipelines/impact_evaluation_pipeline.py +++ b/src/impact/pipelines/impact_evaluation_pipeline.py @@ -10,7 +10,10 @@ from torch.utils.data import DataLoader from tqdm import tqdm -from impact.evaluation.metrics import compute_ndcg_at_k, compute_recall_at_k +from impact.evaluation.metrics import ( + compute_per_sample_ndcg_at_k, + compute_per_sample_recall_at_k, +) from impact.models.actr import ACTR, ACTRHistoryIndex from impact.models.sasrec import ( EvalSequenceBatch, @@ -62,6 +65,27 @@ class _TargetSplits: global_exp: torch.Tensor +@dataclass(frozen=True, slots=True) +class _BatchMetricResult: + """Batch-level evaluation results before global aggregation. + + Stores both the aggregated metric contributions required for dataset-wide averaging + and the per-sample metric values required for statistical significance testing. + + Attributes: + metric_sums: Sum of per-sample metric values for each metric. Used together with + `counts` to compute final dataset-wide means. + counts: Number of valid samples contributing to each evaluation scope. + per_sample_metrics: Raw per-sample metric values of shape [B] for each metric. + For "scoped" metrics, samples outside the scope are not excluded, but rather + contain NaN. + """ + + metric_sums: dict[str, float] + counts: dict[EvalScopeKey, int] + per_sample_metrics: dict[str, torch.Tensor] + + @final class ImpactEvaluationPipeline: """Late-fusion evaluation pipeline for the IMPACT framework. @@ -69,7 +93,8 @@ class ImpactEvaluationPipeline: Combines a trained SASRecNBR model with a deterministic ACT-R scorer via a weighted combination controlled by `ImpactConfig.eval.alpha` (1.0 = SASRec only, 0.0 = ACT-R only). Both models score the full item catalog; their probability distributions are - then fused and evaluated against held-out test sessions. + then fused and evaluated against sessions from the held-out partition (validation + or test). Reported metrics are NDCG@k and Recall@k decomposed across five evaluation scopes: - global: all target items. @@ -329,13 +354,17 @@ def _compute_batch_metrics( self, final_scores: torch.Tensor, target_splits: _TargetSplits, - ) -> tuple[dict[str, float], dict[EvalScopeKey, int]]: - """Compute metric sums (unnormalized) for one batch across all k cutoffs. + ) -> _BatchMetricResult: + """Compute batch-level metric aggregates and per-sample metric values. - For each configured k and each evaluation scope, computes NDCG@k and Recall@k - weighted by the number of valid samples in that scope (value * count). Division - by cumulative counts is done in `evaluate()` so that final metric averages are - correct across batches. + For each configured k cutoff and evaluation scope, computes NDCG@k and Recall@k + on a per-sample basis. Aggregated metric sums are computed for dataset-wide + averaging, while the raw per-sample values are preserved for statistical + significance testing. + + Samples that do not belong to a given evaluation scope receive NaN values. + It allows scope-specific metrics to be aligned with the original batch while + being excluded from dataset-wide aggregation (via `torch.nanmean()`). Args: final_scores: [B, num_items + 1] final score tensor after late-fusion with @@ -344,37 +373,47 @@ def _compute_batch_metrics( `_derive_target_splits`. Returns: - metric_sums: Metric sums keyed by canonical metric name (e.g., "ndcg_at_5", - "recall_global_exp_at_5"). - counts: Number of valid (non-empty) samples per `EvalScopeKey`. + `_BatchMetricResult` containing: + - unnormalized metric sums for dataset-wide aggregation, + - valid sample counts for each evaluation scope, + - raw per-sample metric values. """ + scope_mask: dict[EvalScopeKey, torch.Tensor] = { + "global": (target_splits.global_ != 0).any(dim=-1), + "local_rep": (target_splits.local_rep != 0).any(dim=-1), + "local_exp": (target_splits.local_exp != 0).any(dim=-1), + "global_rep": (target_splits.global_rep != 0).any(dim=-1), + "global_exp": (target_splits.global_exp != 0).any(dim=-1), + } counts: dict[EvalScopeKey, int] = { - "global": target_splits.global_.size(0), - "local_rep": int((target_splits.local_rep != 0).any(dim=-1).sum().item()), - "local_exp": int((target_splits.local_exp != 0).any(dim=-1).sum().item()), - "global_rep": int((target_splits.global_rep != 0).any(dim=-1).sum().item()), - "global_exp": int((target_splits.global_exp != 0).any(dim=-1).sum().item()), + scope: int(mask.sum().item()) for scope, mask in scope_mask.items() } - - metric_scopes: list[tuple[EvalScopeKey, torch.Tensor, int]] = [ - ("global", target_splits.global_, counts["global"]), - ("local_rep", target_splits.local_rep, counts["local_rep"]), - ("local_exp", target_splits.local_exp, counts["local_exp"]), - ("global_rep", target_splits.global_rep, counts["global_rep"]), - ("global_exp", target_splits.global_exp, counts["global_exp"]), + metric_scopes: list[tuple[EvalScopeKey, torch.Tensor]] = [ + ("global", target_splits.global_), + ("local_rep", target_splits.local_rep), + ("local_exp", target_splits.local_exp), + ("global_rep", target_splits.global_rep), + ("global_exp", target_splits.global_exp), ] - batch_metrics: dict[str, float] = {} + metric_sums: dict[str, float] = {} + per_sample_metrics: dict[str, torch.Tensor] = {} + for k in self._config.eval.k_cutoffs: - for scope, targets, count in metric_scopes: - batch_metrics[_metric_name("ndcg", scope, k)] = ( - compute_ndcg_at_k(final_scores, targets, k) * count - ) - batch_metrics[_metric_name("recall", scope, k)] = ( - compute_recall_at_k(final_scores, targets, k) * count - ) + for scope, targets in metric_scopes: + ndcg = compute_per_sample_ndcg_at_k(final_scores, targets, k) + recall = compute_per_sample_recall_at_k(final_scores, targets, k) + + ndcg_name = _metric_name("ndcg", scope, k) + recall_name = _metric_name("recall", scope, k) + + per_sample_metrics[ndcg_name] = ndcg + per_sample_metrics[recall_name] = recall + + metric_sums[ndcg_name] = torch.nansum(ndcg).item() + metric_sums[recall_name] = torch.nansum(recall).item() - return batch_metrics, counts + return _BatchMetricResult(metric_sums, counts, per_sample_metrics) def _derive_target_splits( self, From 01cb6b1d96f35f8979fe523d24f5042d19b03fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20G=C5=82owacz?= Date: Fri, 12 Jun 2026 22:43:49 +0200 Subject: [PATCH 04/12] [#28] feat(pipelines): update evaluate() method of the ImpactEvaluationPipeline to allow the caller specify whether only aggregated metrics should be computed or, optionally, aggregated metrics + raw per-sample metrics (for statistical testing) --- .../pipelines/impact_evaluation_pipeline.py | 76 +++++++++++++++---- 1 file changed, 62 insertions(+), 14 deletions(-) diff --git a/src/impact/pipelines/impact_evaluation_pipeline.py b/src/impact/pipelines/impact_evaluation_pipeline.py index d8e351b..6d1c265 100644 --- a/src/impact/pipelines/impact_evaluation_pipeline.py +++ b/src/impact/pipelines/impact_evaluation_pipeline.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Literal, cast, final, get_args import numpy as np +import polars as pl import structlog import torch from torch.utils.data import DataLoader @@ -27,8 +28,6 @@ if TYPE_CHECKING: from pathlib import Path - import polars as pl - from impact.config import ImpactConfig from impact.schemas import ItemIndex, TensorizedDataset @@ -86,6 +85,23 @@ class _BatchMetricResult: per_sample_metrics: dict[str, torch.Tensor] +@dataclass(frozen=True, slots=True) +class EvaluationResult: + """Result of IMPACT evaluation on a held-out partition. + + Attributes: + metrics: Aggregated metric values (metric name -> mean) computed over the entire + partition. + raw_metrics_df: Per-sample raw metric values, used for statistical significance + testing. Represented as a Polars DataFrame containing one row per evaluated + sample and one column per metric. Each sample is identified by a (uid, + target_ts) key. None unless raw metric collection was explicitly requested. + """ + + metrics: dict[str, float] + raw_metrics_df: pl.DataFrame | None = None + + @final class ImpactEvaluationPipeline: """Late-fusion evaluation pipeline for the IMPACT framework. @@ -103,8 +119,10 @@ class ImpactEvaluationPipeline: - global_rep / global_exp: items repeated from or absent in the user's full interaction history (i.e. long-term novelty). - This decomposition lets the caller analyze the repetition-exploration trade-off - introduced by varying alpha. + This decomposition allows for a granular analysis of the repetition-exploration + trade-off introduced by varying alpha. Furthermore, the pipeline optionally extracts + raw, per-sample metrics to enable post-hoc statistical significance testing between + model variants. """ def __init__( @@ -131,16 +149,17 @@ def evaluate( self, split_partition: TensorizedDataset, sasrec_state_dict_path: Path, - ) -> dict[str, float]: + collect_raw: bool = False, + ) -> EvaluationResult: """Evaluate the IMPACT model on the given split partition. - Loads the SASRecNBR checkpoint, builds the ACT-R history index for test users, + Loads the SASRecNBR checkpoint, builds the ACT-R history index for eval users, and runs batched inference over the full item catalog. Final scores are a combination of SASRec and ACT-R probabilities (late-fusion) controlled by `ImpactConfig.eval.alpha` (1.0 = SASRec only, 0.0 = ACT-R only). - Metrics are computed at every cutoff listed in `ImpactConfig.eval.k_cutoffs` - and returned as a flat dictionary. The key schema is: + Metrics are computed at every cutoff listed in `ImpactConfig.eval.k_cutoffs`. + The aggregated metric keys follow the schema: `{metric}_{scope}_at_{k}` @@ -153,9 +172,13 @@ def evaluate( `TensorizedSplit.val` during hyperparameter search and `TensorizedSplit.test` for final evaluation on the held-out data. sasrec_state_dict_path: Path to the saved SASRecNBR checkpoint. + collect_raw: Whether to collect raw per-sample metric values. When True, + the returned result includes a Polars DataFrame necessary for post-hoc + statistical significance testing. Returns: - Flat metric dictionary with `10 * len(ImpactConfig.eval.k_cutoffs)` entries. + An `EvaluationResult` containing the aggregated metric dictionary and, + if requested, the raw per-sample metrics DataFrame. """ logger.info("Starting evaluation pipeline") @@ -186,6 +209,13 @@ def evaluate( batch_metric_sums: dict[str, float] = dict.fromkeys(metric_names, 0.0) total_counts: dict[str, int] = dict.fromkeys(get_args(EvalScopeKey), 0) + # Accumulators for per-sample raw values (only populated if collect_raw) + raw_uids: list[torch.Tensor] = [] + raw_target_ts: list[torch.Tensor] = [] + raw_metric_values: dict[str, list[torch.Tensor]] = { + name: [] for name in metric_names + } + test_loader = DataLoader[EvalSequenceSample]( dataset=EvalSequenceDataset(split_partition), batch_size=self._config.eval.batch_size, @@ -200,7 +230,6 @@ def evaluate( iterator = cast(Iterator[EvalSequenceBatch], iter(test_loader)) for batch in tqdm(iterator, desc="Evaluation", leave=False): batch = batch.to(device) - sasrec_logits = sasrec.score_all_items(sasrec.encode(context=batch.context)) # Compute global-repetition mask and optionally BLA scores in a single pass @@ -237,16 +266,22 @@ def evaluate( context=batch.context, is_global_repeated=is_global_repeated, ) - batch_metrics, counts = self._compute_batch_metrics( + batch_metric_result = self._compute_batch_metrics( final_scores=final_scores, target_splits=target_splits, ) - for metric, sum_ in batch_metrics.items(): + for metric, sum_ in batch_metric_result.metric_sums.items(): batch_metric_sums[metric] += sum_ - for scope, count in counts.items(): + for scope, count in batch_metric_result.counts.items(): total_counts[scope] += count + if collect_raw: + raw_uids.append(batch.uid.detach().cpu()) + raw_target_ts.append(batch.target_ts.detach().cpu()) + for metric, values in batch_metric_result.per_sample_metrics.items(): + raw_metric_values[metric].append(values.detach().cpu()) + logger.info("Aggregating metrics across batches") metrics: dict[str, float] = { _metric_name(metric, scope, k): ( @@ -258,8 +293,21 @@ def evaluate( for scope in get_args(EvalScopeKey) } + raw_df: pl.DataFrame | None = None + if collect_raw: + raw_df = pl.DataFrame( + { + "uid": torch.cat(raw_uids).numpy(), + "target_ts": torch.cat(raw_target_ts).numpy(), + **{ + metric: torch.cat(values).numpy() + for metric, values in raw_metric_values.items() + }, + } + ) + logger.info("Evaluation complete") - return metrics + return EvaluationResult(metrics=metrics, raw_metrics_df=raw_df) def _load_sasrec(self, state_dict_path: Path, device: torch.device) -> SASRecNBR: """Load SASRecNBR from a checkpoint and set the model to eval mode.""" From 9cb9b7a51f65e21cc9496569aaaa1e4c43c99a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20G=C5=82owacz?= Date: Sat, 13 Jun 2026 00:24:05 +0200 Subject: [PATCH 05/12] [#28] feat(main): integrate the changes from eval pipeline with the main entrypoint to let the caller optionally save raw metrics (for statistical testing) specifying where --- src/impact/main.py | 49 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/impact/main.py b/src/impact/main.py index b619fc9..871ff93 100644 --- a/src/impact/main.py +++ b/src/impact/main.py @@ -193,6 +193,25 @@ def train_sasrec( show_default=True, help="Logging verbosity level.", ) +@click.option( + "--save-raw-metrics", + is_flag=True, + default=False, + help=""" + Collect and save per-sample raw metric values to a Parquet file, for statistical + significance testing. Note that this increase memory usage during evaluation. + """, +) +@click.option( + "--results-dir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help=""" + Directory to save evaluation artifacts (aggregated metrics JSON, and raw + per-sample metrics Parquet if --save-raw-metrics is set). + Defaults to /reports/impact-evaluation. + """, +) @click.option( "--raw-data-dir", type=click.Path(file_okay=False, path_type=Path), @@ -233,6 +252,8 @@ def evaluate_impact( sasrec_checkpoint_path: Path, partition: Literal["val", "test"], log_level: str = "INFO", + save_raw_metrics: bool = False, + results_dir: Path | None = None, raw_data_dir: Path | None = None, interim_data_dir: Path | None = None, processed_data_dir: Path | None = None, @@ -248,14 +269,16 @@ def evaluate_impact( """ timestamp = exec_time or datetime.datetime.now(datetime.UTC) + results_dir = results_dir or PROJECT_ROOT / "reports" / "impact-evaluation" raw_data_dir = raw_data_dir or PROJECT_ROOT / "data" / "raw" interim_data_dir = interim_data_dir or PROJECT_ROOT / "data" / "interim" processed_data_dir = processed_data_dir or PROJECT_ROOT / "data" / "processed" base_logs_dir = base_logs_dir or PROJECT_ROOT / "logs" + results_dir.mkdir(parents=True, exist_ok=True) # Configure logging logs_dir = _get_logs_dir(base_dir=base_logs_dir, timestamp=timestamp) - log_filename = f"{timestamp.strftime('%Y-%m-%d_%H-%M-%S')}_train-sasrec" + log_filename = f"{timestamp.strftime('%Y-%m-%d_%H-%M-%S')}_evaluate-impact" configure_logging( level=logging.getLevelNamesMapping()[log_level], log_file=logs_dir / f"{log_filename}.log", @@ -298,10 +321,28 @@ def evaluate_impact( listens_lf=listens_lf, ) logger.info("Running IMPACT evaluation...") - split_partition = cast(TensorizedDataset, getattr(tensorized_splits, partition)) - metrics = evaluation_pipeline.evaluate(split_partition, sasrec_checkpoint_path) - logger.info("IMPACT evaluation complete", **metrics) + + eval_result = evaluation_pipeline.evaluate( + split_partition=split_partition, + sasrec_state_dict_path=sasrec_checkpoint_path, + collect_raw=save_raw_metrics, + ) + logger.info("IMPACT evaluation complete", **eval_result.metrics) + + metrics_path = results_dir / f"metrics_{impact_config.display_id}.json" + with metrics_path.open("w") as f: + json.dump(eval_result.metrics, f, indent=4) + logger.info("Aggregated metrics saved", metrics_path=metrics_path.as_posix()) + + if save_raw_metrics: + if eval_result.raw_metrics_df is None: + raise RuntimeError( + "save_raw_metrics=True but evaluate() returned no raw_metrics_df" + ) + raw_path = results_dir / f"raw_metrics_{impact_config.display_id}.parquet" + eval_result.raw_metrics_df.write_parquet(raw_path) + logger.info("Raw metrics saved", raw_metrics_path=raw_path.as_posix()) @cli.command(name="sasrec-grid-search") From 30228757c824dbe7ae11050d434452c5429b71be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20G=C5=82owacz?= Date: Sat, 13 Jun 2026 00:40:29 +0200 Subject: [PATCH 06/12] [#28] chore: add scipy lib to project dependencies for statistical hypothesis testing implementations (e.g. built-in Wilcoxon test) --- pyproject.toml | 1 + uv.lock | 97 +++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 81 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f92e900..e407e24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "pyarrow>=24.0.0", "python-dotenv>=1.2.2", "pyyaml>=6.0.3", + "scipy>=1.17.1", "structlog>=25.5.0", "tqdm>=4.67.3", ] diff --git a/uv.lock b/uv.lock index 6bcd4b5..54fcfbb 100644 --- a/uv.lock +++ b/uv.lock @@ -600,14 +600,15 @@ dependencies = [ { name = "pyarrow" }, { name = "python-dotenv" }, { name = "pyyaml" }, + { name = "scipy" }, { name = "structlog" }, { name = "tqdm" }, ] [package.optional-dependencies] cpu = [ - { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-6-impact-cpu') or (extra == 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu128') or (extra == 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu132') or (extra == 'extra-6-impact-cu128' and extra == 'extra-6-impact-cu132')" }, - { name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform != 'darwin' and extra == 'extra-6-impact-cpu') or (extra == 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu128') or (extra == 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu132') or (extra == 'extra-6-impact-cu128' and extra == 'extra-6-impact-cu132')" }, + { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(python_full_version < '3.15' and sys_platform == 'darwin' and extra == 'extra-6-impact-cpu') or (python_full_version >= '3.15' and extra == 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu128') or (python_full_version >= '3.15' and extra == 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu132') or (sys_platform != 'darwin' and extra == 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu128') or (sys_platform != 'darwin' and extra == 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu132') or (sys_platform != 'darwin' and extra == 'extra-6-impact-cu128' and extra == 'extra-6-impact-cu132') or (extra != 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu128' and extra == 'extra-6-impact-cu132')" }, + { name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(python_full_version >= '3.15' and extra == 'extra-6-impact-cpu') or (python_full_version < '3.15' and extra == 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu128') or (python_full_version < '3.15' and extra == 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu132') or (python_full_version < '3.15' and extra == 'extra-6-impact-cu128' and extra == 'extra-6-impact-cu132') or (sys_platform != 'darwin' and extra == 'extra-6-impact-cpu') or (sys_platform != 'darwin' and extra == 'extra-6-impact-cu128' and extra == 'extra-6-impact-cu132') or (extra != 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu128' and extra == 'extra-6-impact-cu132')" }, ] cu128 = [ { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" } }, @@ -639,6 +640,7 @@ requires-dist = [ { name = "pyarrow", specifier = ">=24.0.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "scipy", specifier = ">=1.17.1" }, { name = "structlog", specifier = ">=25.5.0" }, { name = "torch", marker = "extra == 'cpu'", specifier = ">=2.12.0", index = "https://download.pytorch.org/whl/cpu", conflict = { package = "impact", extra = "cpu" } }, { name = "torch", marker = "extra == 'cu128'", specifier = ">=2.10.0", index = "https://download.pytorch.org/whl/cu128", conflict = { package = "impact", extra = "cu128" } }, @@ -1996,6 +1998,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + [[package]] name = "setuptools" version = "81.0.0" @@ -2112,18 +2175,17 @@ name = "torch" version = "2.12.0" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.15' and sys_platform == 'darwin'", "python_full_version == '3.14.*' and sys_platform == 'darwin'", "python_full_version < '3.14' and sys_platform == 'darwin'", ] dependencies = [ - { name = "filelock", marker = "sys_platform == 'darwin'" }, - { name = "fsspec", marker = "sys_platform == 'darwin'" }, - { name = "jinja2", marker = "sys_platform == 'darwin'" }, - { name = "networkx", marker = "sys_platform == 'darwin'" }, - { name = "setuptools", marker = "sys_platform == 'darwin'" }, - { name = "sympy", marker = "sys_platform == 'darwin'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, + { name = "filelock", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "fsspec", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "jinja2", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "networkx", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "setuptools", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "sympy", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b41339df93d491435e790ff8bcbae1c0ce777175889bfd1281d119862793e6a2", upload-time = "2026-05-12T16:20:12Z" }, @@ -2144,18 +2206,19 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.15' and sys_platform == 'darwin'", "python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "filelock", marker = "sys_platform != 'darwin'" }, - { name = "fsspec", marker = "sys_platform != 'darwin'" }, - { name = "jinja2", marker = "sys_platform != 'darwin'" }, - { name = "networkx", marker = "sys_platform != 'darwin'" }, - { name = "setuptools", marker = "sys_platform != 'darwin'" }, - { name = "sympy", marker = "sys_platform != 'darwin'" }, - { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, + { name = "filelock", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "fsspec", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "jinja2", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "networkx", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "setuptools", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "sympy", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.12.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:b9d0e8eed0af9321ffb12b75f4aca371b071254f12cf75875d5a8e7cc8f52b51", upload-time = "2026-05-12T23:16:33Z" }, From 08b89e7d55abfdff9ac073dd5425eca70b9b4169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20G=C5=82owacz?= Date: Sat, 25 Jul 2026 07:47:15 +0200 Subject: [PATCH 07/12] [#28] feat(evaluation): implement statistical significance testing for IMPACT evaluation using Wilcoxon signed-rank test; add scipy-stubs library to resolve typing issues --- pyproject.toml | 2 + src/impact/evaluation/__init__.py | 6 ++ src/impact/evaluation/significance.py | 126 +++++++++++++++++++++++ uv.lock | 140 ++++++++++++++++---------- 4 files changed, 221 insertions(+), 53 deletions(-) create mode 100644 src/impact/evaluation/significance.py diff --git a/pyproject.toml b/pyproject.toml index e407e24..bbb60c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dev = [ "mypy>=2.1.0", "pandas-stubs>=3.0.0.260204", "ruff>=0.15.13", + "scipy-stubs>=1.18.0.1", "types-psutil>=7.2.2.20260518", "types-pyyaml>=6.0.12.20260518", "types-tqdm>=4.67.3.20260518", @@ -255,6 +256,7 @@ executionEnvironments = [ { root = "src/impact/main.py", reportExplicitAny = false }, { root = "src/impact/utils.py", reportImplicitStringConcatenation = false }, { root = "src/impact/dataset/fetcher.py", reportUnknownVariableType = false }, + { root = "src/impact/evaluation/significance.py", reportUnknownArgumentType = false, reportAttributeAccessIssue = false, reportAny = false }, { root = "src/impact/features/sequence_extraction.py", reportExplicitAny = false }, { root = "src/impact/features/tensorization.py", reportAny = false, reportExplicitAny = false }, { root = "src/impact/models/sasrec/dataset.py", reportAny = false }, diff --git a/src/impact/evaluation/__init__.py b/src/impact/evaluation/__init__.py index ef97bd1..ec64d16 100644 --- a/src/impact/evaluation/__init__.py +++ b/src/impact/evaluation/__init__.py @@ -4,8 +4,14 @@ compute_per_sample_recall_at_k, compute_recall_at_k, ) +from .significance import ( + WilcoxonResult, + compare_models, +) __all__ = [ + "WilcoxonResult", + "compare_models", "compute_ndcg_at_k", "compute_per_sample_ndcg_at_k", "compute_per_sample_recall_at_k", diff --git a/src/impact/evaluation/significance.py b/src/impact/evaluation/significance.py new file mode 100644 index 0000000..8cf529d --- /dev/null +++ b/src/impact/evaluation/significance.py @@ -0,0 +1,126 @@ +"""Statistical significance testing for IMPACT evaluation results.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +import numpy as np +import structlog +from scipy import stats + +if TYPE_CHECKING: + import polars as pl + from numpy.typing import NDArray + +logger = structlog.stdlib.get_logger(__name__) + + +@dataclass(frozen=True, slots=True) +class WilcoxonResult: + """Result of a Wilcoxon signed-rank test comparing two model runs (paired). + + Attributes: + metric: Name of the compared metric (e.g., ndcg_at_10). + statistic: Wilcoxon signed-rank statistic. + p_value: One-sided p-value. + effect_size_r: Standardized effect size r = |Z| / sqrt(n). + n_total: Number of all valid pairs, including zero differences. + n_nonzero: Number or pairs with d != 0. + n_improved: Number of pairs with d > 0. + n_worsened: Number of pairs with d < 0. + """ + + metric: str + statistic: float + p_value: float + effect_size_r: float + n_total: int # all valid pairs (including zero differences) + n_nonzero: int # pairs with d != 0, used for the test + n_improved: int # pairs with d > 0 + n_worsened: int # pairs with d < 0 + + +def compare_models( + raw_f_df: pl.DataFrame, + raw_fplus_df: pl.DataFrame, + metric: str = "ndcg_at_10", +) -> WilcoxonResult: + """Compare two paired evaluation runs using the Wilcoxon signed-rank test. + + Both dataframes must contain raw per-sample metric values, one row per (uid, + target_ts) test sample, as produced by + `ImpactEvaluationPipeline.evaluate(collect_raw=True)`. + + Args: + raw_f_df: Raw per-sample metrics for the baseline model f (pure SASRec). + raw_fplus_df: Raw per-sample metrics for the extended model f+ (IMPACT, with + 0.0 < alpha < 1.0). + metric: Name of the per-sample metric column to compare. + + Returns: + WilcoxonResult with the test statistic, p-value, effect size, and the number + of paired samples used. + + Raises: + ValueError: If no valid paired samples remain after joining and dropping NaN + values (e.g. due to mismatched (uid, target_ts) keys or the metric being + undefined for all samples in a given scope). + """ + merged_df = raw_fplus_df.join( + raw_f_df, on=["uid", "target_ts"], suffix="_f", how="inner" + ) + + a = merged_df[metric].to_numpy() + b = merged_df[f"{metric}_f"].to_numpy() + + # Drop pairs where the metric is undefined for either run (e.g. scoped metrics like + # "ndcg_local_exp_at_10" where the sample has no items in that scope) + valid_mask = ~(np.isnan(a) | np.isnan(b)) + a, b = a[valid_mask], b[valid_mask] + + if a.size == 0: + raise ValueError( + f"No valid paired samples for metric '{metric}' after dropping NaNs" + ) + + d: NDArray[np.floating] = a - b + + # Number of non-zero paired differences used by the Wilcoxon test + n = int(np.count_nonzero(d)) + if n == 0: + raise ValueError( + f"All paired differences for metric '{metric}' are zero (test is undefined)" + ) + + logger.info( + "Running Wilcoxon signed-rank test", + metric=metric, + raw_f_df_shape=raw_f_df.shape, + raw_fplus_df_shape=raw_fplus_df.shape, + n_pairs=merged_df.height, + n_valid=a.size, + n_nonzero=n, + ) + + # Alternative refers to the direction of the alternative hypothesis. "greater" tests + # whether f+ outperforms f (median of f+ - f > 0). + res = stats.wilcoxon( + d, + alternative="greater", + zero_method="wilcox", + method="approx", + ) + z_stat = cast(float, res.zstatistic) + effect_size_r = float(abs(z_stat) / np.sqrt(n)) + + return WilcoxonResult( + metric=metric, + statistic=float(res.statistic), + p_value=float(res.pvalue), + effect_size_r=effect_size_r, + n_total=a.size, + n_nonzero=n, + n_improved=int(np.count_nonzero(d > 0)), + n_worsened=int(np.count_nonzero(d < 0)), + ) diff --git a/uv.lock b/uv.lock index 54fcfbb..7ed7950 100644 --- a/uv.lock +++ b/uv.lock @@ -624,6 +624,7 @@ dev = [ { name = "mypy" }, { name = "pandas-stubs" }, { name = "ruff" }, + { name = "scipy-stubs" }, { name = "types-psutil" }, { name = "types-pyyaml" }, { name = "types-tqdm" }, @@ -656,6 +657,7 @@ dev = [ { name = "mypy", specifier = ">=2.1.0" }, { name = "pandas-stubs", specifier = ">=3.0.0.260204" }, { name = "ruff", specifier = ">=0.15.13" }, + { name = "scipy-stubs", specifier = ">=1.18.0.1" }, { name = "types-psutil", specifier = ">=7.2.2.20260518" }, { name = "types-pyyaml", specifier = ">=6.0.12.20260518" }, { name = "types-tqdm", specifier = ">=4.67.3.20260518" }, @@ -1121,6 +1123,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, ] +[[package]] +name = "numpy-typing-compat" +version = "20260602.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/73/e331473d3db84a8e8883ac07bfd63a8ce9eb7196acbb672bda1f5b8d3294/numpy_typing_compat-20260602.2.4.tar.gz", hash = "sha256:e4eb661f312a7ad5805677967d5879e04fd7b97627fe910121ce7b1f43aa748c", size = 4603, upload-time = "2026-06-02T15:52:38.572Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/a8/94811eedac4cef5ef7df4b24e06715fa371724782e86a4573f5d172c8473/numpy_typing_compat-20260602.2.4-py3-none-any.whl", hash = "sha256:78d33917d5f6921f8d1c549db347a5b8d9768853e36796b08208c81f5b620977", size = 5879, upload-time = "2026-06-02T15:52:33.214Z" }, +] + [[package]] name = "nvidia-cublas" version = "13.4.0.1" @@ -1446,6 +1460,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/99/4c9c0c329bf9fc125008c3b54c7c94c0023518d06fc025ae36431375e1fe/nvidia_nvtx_cu12-12.8.90-py3-none-win_amd64.whl", hash = "sha256:619c8304aedc69f02ea82dd244541a83c3d9d40993381b3b590f1adaed3db41e", size = 56492, upload-time = "2025-03-07T01:52:24.69Z" }, ] +[[package]] +name = "optype" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu128') or (extra == 'extra-6-impact-cpu' and extra == 'extra-6-impact-cu132') or (extra == 'extra-6-impact-cu128' and extra == 'extra-6-impact-cu132')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/51/51dc9b1009e020f44703933d4d1ee3429c647c026ce7806b37ee2b257998/optype-0.18.0.tar.gz", hash = "sha256:ea10dee61b15ca299ed0d97025d362585c4dfc5481159bb999a1d0d414bbcb04", size = 59967, upload-time = "2026-06-07T22:13:17.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/91/2b064a117cb2593bc3eb04ee994134ca2a6bf2162c92961769947e3258cc/optype-0.18.0-py3-none-any.whl", hash = "sha256:91822ed8516e7a4f225ba53d30f291776c35f31553fb952d7cda98286679c5a6", size = 73410, upload-time = "2026-06-07T22:13:16.324Z" }, +] + +[package.optional-dependencies] +numpy = [ + { name = "numpy" }, + { name = "numpy-typing-compat" }, +] + [[package]] name = "packaging" version = "26.2" @@ -2000,63 +2032,65 @@ wheels = [ [[package]] name = "scipy" -version = "1.17.1" +version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, - { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, - { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, - { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, - { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, - { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, - { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, - { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, - { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, - { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, - { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, - { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, - { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, - { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, - { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, - { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, - { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, - { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, - { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, - { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, - { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, - { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, - { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, - { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, - { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, - { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, - { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "scipy-stubs" +version = "1.18.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "optype", extra = ["numpy"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/c6/c4c80bd13c0eec99121922c50cd1c72600672307b28e4ba67f636d6b298d/scipy_stubs-1.18.0.1.tar.gz", hash = "sha256:761cb35106200c90a37034d3c8120d3899d673de1e5879d3a2ac1d6816c62327", size = 410862, upload-time = "2026-07-12T21:09:29.993Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/0c/78404528783677077f072288724ff8ef8381bbc219b110e3147e60361b6d/scipy_stubs-1.18.0.1-py3-none-any.whl", hash = "sha256:704408c5f03a33924c9fed6dc9e25926dc331ed508d52f1beece0188525cebad", size = 616217, upload-time = "2026-07-12T21:09:28.45Z" }, ] [[package]] From 719b0fded76fac51c5518e53ca0ec4f2efd2631f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20G=C5=82owacz?= Date: Fri, 31 Jul 2026 17:13:03 +0200 Subject: [PATCH 08/12] [#28] feat(scripts): refactor scripts/ folder - define a cli.py entrypoint, tables_data.py for pure table data computation and tables_render_latex.py for rendering the computed data into LaTeX tables --- pyproject.toml | 6 +- scripts/__init__.py | 0 scripts/cli.py | 142 ++++++++++++++++++++ scripts/tables.py | 229 --------------------------------- scripts/tables_data.py | 126 ++++++++++++++++++ scripts/tables_render_latex.py | 124 ++++++++++++++++++ scripts/types.py | 6 + uv.lock | 167 +++++++++++++++++++++++- 8 files changed, 564 insertions(+), 236 deletions(-) create mode 100644 scripts/__init__.py create mode 100644 scripts/cli.py delete mode 100644 scripts/tables.py create mode 100644 scripts/tables_data.py create mode 100644 scripts/tables_render_latex.py create mode 100644 scripts/types.py diff --git a/pyproject.toml b/pyproject.toml index bbb60c9..cb5fbb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "impact" -version = "0.1.0" +version = "0.2.0" description = "Intention-Modulated Personalized ACT-R: an extension to the ACT-R cognitive architecture that modulates memory traces based on user intent, applied to sequential music recommendations" readme = "README.md" authors = [ @@ -13,6 +13,7 @@ dependencies = [ "huggingface-hub>=1.15.0", "jinja2>=3.1.6", "pandas>=3.0.3", + "pandera[polars]>=0.32.1", "polars[rt64]>=1.40.1", "pyarrow>=24.0.0", "python-dotenv>=1.2.2", @@ -249,8 +250,7 @@ reportPrivateImportUsage = false # Assume that the user knows what they're doing executionEnvironments = [ { root = "tests", reportAny = false, reportPrivateUsage = false }, # Suppress specific errors in tests - { root = "scripts/plots.py", reportAny = false, reportExplicitAny = false }, - { root = "scripts/tables.py", reportAny = false }, + { root = "scripts", reportAny = false, reportExplicitAny = false }, { root = "src/impact/cli.py", reportAny = false, reportExplicitAny = false }, { root = "src/impact/config.py", reportAny = false, reportExplicitAny = false }, { root = "src/impact/main.py", reportExplicitAny = false }, diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/cli.py b/scripts/cli.py new file mode 100644 index 0000000..85ca211 --- /dev/null +++ b/scripts/cli.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Final + +import click + +from impact.cli import inject_impact_config +from impact.logger import configure_logging + +from . import tables_render_latex +from .tables_data import compute_data_funnel_stats_for_variants + +if TYPE_CHECKING: + from collections.abc import Callable + + from impact.config import ImpactConfig + + from .types import Language, TableFormat + +PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[1] + + +@click.group() +def cli() -> None: + """Main script's CLI entry point.""" + pass + + +@cli.group(name="tables") +def tables() -> None: + """Commands for generating tables (used in thesis/documentation).""" + pass + + +@tables.command(name="data-funnel-stats") +@click.option( + "--log-level", + type=click.Choice(choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), + default="WARNING", + show_default=True, + help="Logging level.", +) +@click.option( + "--lang", + type=click.Choice(["pl", "en"], case_sensitive=False), + default="en", + show_default=True, + help="Language for the table headers and textual content.", +) +@click.option( + "--format", + "fmt", + type=click.Choice(["latex", "markdown"], case_sensitive=False), + default="latex", + show_default=True, + help="Table format.", +) +@click.option( + "--out-dir", + "-o", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help=""" + Output directory for the LaTeX code and corresponding ImpactConfig YAML dumps. + Defaults to project root / 'reports' / 'tables' / 'data-funnel-stats'. + """, +) +@click.option( + "--raw-data-dir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help=""" + Root directory for raw datasets (the original, immutable data dump). + Defaults to project root / 'data' / 'raw'. + """, +) +@click.option( + "--interim-data-dir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help=""" + Root directory for interim data (intermediate data that has been transformed). + Defaults to project root / 'data' / 'interim'. + """, +) +@click.option( + "--processed-data-dir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help=""" + Root directory for processed data (the final datasets for model training and + evaluation). Defaults to project root / 'data' / 'processed'. + """, +) +@inject_impact_config +def generate_data_funnel_stats_table( + impact_config: ImpactConfig, + log_level: str = "INFO", + lang: Language = "en", + fmt: TableFormat = "latex", + out_dir: Path | None = None, + raw_data_dir: Path | None = None, + interim_data_dir: Path | None = None, + processed_data_dir: Path | None = None, +) -> None: + """Generate a table of record counts across data preparation stages.""" + configure_logging( + level=logging.getLevelNamesMapping()[log_level], + ) # Configure logging without piping logs to files + + out_dir = out_dir or PROJECT_ROOT / "reports" / "tables" / "data-funnel-stats" + out_dir.mkdir(parents=True, exist_ok=True) + + data_df, impact_config_by_display_id = compute_data_funnel_stats_for_variants( + base_impact_config=impact_config, + raw_data_dir=raw_data_dir or PROJECT_ROOT / "data" / "raw", + interim_data_dir=interim_data_dir or PROJECT_ROOT / "data" / "interim", + processed_data_dir=processed_data_dir or PROJECT_ROOT / "data" / "processed", + ) + + renderer_ext_by_fmt: dict[TableFormat, tuple[Callable[..., str], str]] = { + "latex": (tables_render_latex.render_data_funnel_stats, "tex"), + # "markdown": tables_render_markdown.render_data_funnel_stats, + } + + rendered_table = renderer_ext_by_fmt[fmt][0](data_df, lang.lower()) + ext = renderer_ext_by_fmt[fmt][1] + + for display_id, config in impact_config_by_display_id.items(): + config_dump_path = out_dir / f"{display_id}.yaml" + config.write_yaml(config_dump_path) + click.echo(f"Config dumped to {config_dump_path.as_posix()}") + + out_file = out_dir / f"data_funnel_stats.{ext}" + _ = out_file.write_text(rendered_table) + click.echo(f"Table saved at {out_file.as_posix()}") + + +if __name__ == "__main__": + cli() diff --git a/scripts/tables.py b/scripts/tables.py deleted file mode 100644 index b69d535..0000000 --- a/scripts/tables.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Module for creating tables in LaTeX format that are presented in the thesis.""" - -import dataclasses -import logging -import string -from pathlib import Path -from typing import NamedTuple - -import click -import polars as pl -import structlog - -from impact.cli import inject_impact_config -from impact.config import ImpactConfig -from impact.dataset import DatasetSize -from impact.logger import configure_logging -from impact.pipelines import DataPipeline - -logger = structlog.stdlib.get_logger(__name__) - - -@click.group() -def cli() -> None: - """CLI entrypoint for the functions defined in this module.""" - pass - - -@cli.command(name="data-funnel-stats") -@click.option( - "--log-level", - type=click.Choice(choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), - default="INFO", - help=""" - Logging level. - """, -) -@click.option( - "--lang", - type=click.Choice(["pl", "en"], case_sensitive=False), - default="en", - help="Language for the table headers and textual content (pl or en).", -) -@click.option( - "--out-dir", - "-o", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help=""" - Output directory for the LaTeX code and corresponding PipelineConfig YAML dumps. - Defaults to project root / 'reports' / 'tables' / 'data-funnel-stats'. - """, -) -@click.option( - "--raw-data-dir", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help=""" - Root directory for raw data (the original, immutable data dump). - Defaults to project root / 'data' / 'raw'. - """, -) -@click.option( - "--interim-data-dir", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help=""" - Root directory for interim data (intermediate data that has been transformed). - Defaults to project root / 'data' / 'interim'. - """, -) -@click.option( - "--processed-data-dir", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help=""" - Root directory for processed data (the final, canonical datasets for modeling). - Defaults to project root / 'data' / 'processed'. - """, -) -@inject_impact_config -def generate_data_funnel_stats_table( - impact_config: ImpactConfig, - log_level: str = "INFO", - lang: str = "en", - out_dir: Path | None = None, - raw_data_dir: Path | None = None, - interim_data_dir: Path | None = None, - processed_data_dir: Path | None = None, -) -> None: - """Generate a LaTeX table of record counts across data preparation stages. - - Compares Yambda-50M and Yambda-5B dataset variants. - """ - configure_logging( - level=logging.getLevelNamesMapping()[log_level], - ) # Configure logging without piping logs to files - - translations: dict[str, dict[str, str]] = { - "pl": { - "stage": "Etap przygotowania danych", - "raw": "Dane wejściowe ($E$)", - "sessionized": "Sekwencja odsłuchów z identyfikatorem sesji ($S_{ses}$)", - "sequenced": "Sekwencje sesji odsłuchowych ($S_{sek}$)", - "caption": "Liczba rekordów po kolejnych etapach przygotowania zbioru eksperymentalnego.", # noqa: E501 - "reproducibility_note": ( - "Konfiguracja IMPACT Framework: \\texttt{$display_id}~" - "(\\cref{$appendix_label})." - ), - }, - "en": { - "stage": "Data preparation stage", - "raw": "Raw data ($E$)", - "sessionized": "Listen events with session identifiers ($S_{ses}$)", - "sequenced": "Listening session sequences ($S_{sek}$)", - "caption": "Record counts across data preparation stages for the experimental dataset.", # noqa: E501 - "reproducibility_note": ( - "The IMPACT Framework configuration: \\texttt{$display_id}~" - "(\\cref{$appendix_label})." - ), - }, - } - t = translations[lang.lower()] - - project_root = Path(__file__).resolve().parents[1] - pipeline = DataPipeline( - raw_dir=raw_data_dir or project_root / "data" / "raw", - interim_dir=interim_data_dir or project_root / "data" / "interim", - processed_dir=processed_data_dir or project_root / "data" / "processed", - config=impact_config, - ) - - out_dir = out_dir or project_root / "reports" / "tables" / "data-funnel-stats" - out_dir.mkdir(parents=True, exist_ok=True) - - class _VariantConfig(NamedTuple): - step: int - dataset_name: str # Name of the dataset for the "reporting" (in the thesis) - marker: str # Marker for the footnotes - - variants: dict[DatasetSize, _VariantConfig] = { - DatasetSize.SIZE_50M: _VariantConfig( - step=5, - dataset_name="Yambda-50M", - marker="a", - ), - DatasetSize.SIZE_5B: _VariantConfig( - step=20, - dataset_name="Yambda-5B", - marker="b", - ), - } - - rows: list[dict[str, str | int]] = [] - footnotes: list[str] = [] - - for ds_size, variant in variants.items(): - logger.info(f"Processing {ds_size.name} dataset variant") - variant_config = dataclasses.replace( - impact_config, - dataset=dataclasses.replace(impact_config.dataset, size=ds_size), - sequence=dataclasses.replace(impact_config.sequence, step=variant.step), - ) - pipeline.config = variant_config # Switch pipeline config - - events_lf = pipeline.get_raw_events() - sessions_lf = pipeline.get_sessions() - sequences_lf = pipeline.get_sequences() - - tnote = f"\\tnote{{\\hyperlink{{note:{variant.marker}}}{{{variant.marker}}}}}" - rows.append({ - "dataset": f"{variant.dataset_name}{tnote}", - # TODO: report also the number of valid listening events - "raw": _format_num(events_lf.select(pl.len()).collect().item()), - "sessionized": _format_num(sessions_lf.select(pl.len()).collect().item()), - "sequenced": _format_num(sequences_lf.select(pl.len()).collect().item()), - }) # fmt: skip - - hypertarget = f"\\hypertarget{{note:{variant.marker}}}{{{variant.marker}}}" - note_text = string.Template(t["reproducibility_note"]).substitute( - display_id=variant_config.display_id, - appendix_label=f"appendix:impactconfig:{variant_config.display_id}", - ) - footnotes.append(f"\\item[{hypertarget}]: {note_text}") - - config_dump_path = out_dir / f"{variant_config.display_id}.yaml" - variant_config.write_yaml(config_dump_path) - click.echo(f"Config dumped to {config_dump_path.as_posix()}") - - df = pl.DataFrame({ - t["stage"]: [t["raw"], t["sessionized"], t["sequenced"]], - **{r["dataset"]: [r["raw"], r["sessionized"], r["sequenced"]] for r in rows} - }) # fmt: skip - - tablenotes_latex = ( - "\\begin{tablenotes}[flushleft]\n" - "\\footnotesize\n" - f"{'\n'.join(footnotes)}\n" - "\\end{tablenotes}\n" - ) - latex_output = ( - df.to_pandas() - .style.hide(axis="index") - # Generate LaTeX table using Pandas Styler - .to_latex( - hrules=True, - environment="threeparttable", # To allow table footnotes - position="H", - position_float="centering", - label="tab:data_funnel_stats", - caption=" ".join(t["caption"].split()), - column_format="lrr", # 'l' for the text, 'r' for the numbers - ) - .replace("\\end{tabular}\n", f"\\end{{tabular}}\n{tablenotes_latex}") - ) - - out_file = out_dir / "tab:data_funnel_stats.tex" - with click.open_file(out_file, "w") as f: - click.echo(latex_output, file=f) - - click.echo(f"LaTeX table saved at {out_file.as_posix()}") - - -def _format_num(n: int) -> str: - """Format integer with space as thousands separator.""" - return f"{n:,}".replace(",", " ") - - -if __name__ == "__main__": - cli() diff --git a/scripts/tables_data.py b/scripts/tables_data.py new file mode 100644 index 0000000..6926fe3 --- /dev/null +++ b/scripts/tables_data.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import dataclasses +from typing import TYPE_CHECKING, Final, NamedTuple, cast + +import pandera.polars as pa +import polars as pl +import structlog +from pandera.typing.polars import Series # noqa: TC002 + +from impact.dataset import DatasetSize +from impact.pipelines import DataPipeline + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + from pandera.typing.polars import DataFrame + + from impact.config import ImpactConfig + +logger = structlog.stdlib.get_logger(__name__) + + +class DataFunnelStatsSchema(pa.DataFrameModel): + """Schema for the data funnel stats table.""" + + dataset_variant: Series[pl.String] = pa.Field() + config_display_id: Series[pl.String] = pa.Field() + all_events: Series[pl.Int64] = pa.Field() + valid_listen_events: Series[pl.Int64] = pa.Field() + listening_sessions: Series[pl.Int64] = pa.Field() + sequences_of_sessions: Series[pl.Int64] = pa.Field() + train_split: Series[pl.Int64] = pa.Field() + val_split: Series[pl.Int64] = pa.Field() + test_split: Series[pl.Int64] = pa.Field() + + +class DataFunnelVariant(NamedTuple): + """Dataset variant to be included in data funnel stats table, e.g. Yambda-50M.""" + + dataset_size: DatasetSize + sequence_step: int + display_name: str # e.g. "Yambda-50M" + + +DEFAULT_VARIANTS: Final[list[DataFunnelVariant]] = [ + DataFunnelVariant(DatasetSize.SIZE_50M, sequence_step=5, display_name="Yambda-50M"), + DataFunnelVariant(DatasetSize.SIZE_5B, sequence_step=20, display_name="Yambda-5B"), +] + + +def compute_data_funnel_stats_for_variants( + base_impact_config: ImpactConfig, + raw_data_dir: Path, + interim_data_dir: Path, + processed_data_dir: Path, + variants: Sequence[DataFunnelVariant] = DEFAULT_VARIANTS, +) -> tuple[DataFrame[DataFunnelStatsSchema], dict[str, ImpactConfig]]: + """Compute data funnel stats for the given variants (one row for each variant).""" + rows: list[pl.DataFrame] = [] + resolved_configs: dict[str, ImpactConfig] = {} + + for variant in variants: + variant_config = dataclasses.replace( + base_impact_config, + dataset=dataclasses.replace( + base_impact_config.dataset, + size=variant.dataset_size, + ), + sequence=dataclasses.replace( + base_impact_config.sequence, + step=variant.sequence_step, + ), + ) + logger.info("Computing data funnel stats", variant=variant.display_name) + stats_df = compute_data_funnel_stats( + variant_config, raw_data_dir, interim_data_dir, processed_data_dir + ) + rows.append( + stats_df.with_columns( + pl.lit(variant.display_name).alias("dataset_variant"), + pl.lit(variant_config.display_id).alias("config_display_id"), + ) + ) + resolved_configs[variant_config.display_id] = variant_config + + return DataFunnelStatsSchema.validate(pl.concat(rows)), resolved_configs + + +def compute_data_funnel_stats( + impact_config: ImpactConfig, + raw_data_dir: Path, + interim_data_dir: Path, + processed_data_dir: Path, +) -> pl.DataFrame: + """Compute data funnel stats.""" + pipeline = DataPipeline( + raw_dir=raw_data_dir, + interim_dir=interim_data_dir, + processed_dir=processed_data_dir, + config=impact_config, + ) + + events_lf = pipeline.get_raw_events() + listen_events_lf = pipeline.get_listen_events() + sessions_lf = pipeline.get_sessions() + sequences_lf = pipeline.get_sequences() + splits, _ = pipeline.get_splits() + + return pl.DataFrame( + { + "all_events": _lf_len(events_lf), + "valid_listen_events": _lf_len(listen_events_lf), + "listening_sessions": _lf_len(sessions_lf), + "sequences_of_sessions": _lf_len(sequences_lf), + "train_split": _lf_len(splits.train.lf), + "val_split": _lf_len(splits.val.lf), + "test_split": _lf_len(splits.test.lf), + } + ) + + +def _lf_len(lf: pl.LazyFrame) -> int: + """Return the number of rows in the given LazyFrame.""" + return cast(int, lf.select(pl.len()).collect().item()) diff --git a/scripts/tables_render_latex.py b/scripts/tables_render_latex.py new file mode 100644 index 0000000..619098e --- /dev/null +++ b/scripts/tables_render_latex.py @@ -0,0 +1,124 @@ +"""Module for creating tables in LaTeX format that are presented in the thesis.""" + +from __future__ import annotations + +import string +from typing import TYPE_CHECKING + +import polars as pl +import structlog + +from .tables_data import DataFunnelStatsSchema + +if TYPE_CHECKING: + from .types import Language + +logger = structlog.stdlib.get_logger(__name__) + + +def render_data_funnel_stats( + data_funnel_stats_df: pl.DataFrame, + lang: Language = "en", +) -> str: + """Generate a LaTeX table of record counts across data preparation stages.""" + translations: dict[Language, dict[str, str]] = { + "pl": { + "stage": "Etap przygotowania danych", + "all_events": "Wszystkie interakcje $E$", + "valid_listen_events": "Odsłuchy $E_{\\text{listen}}$", + "listening_sessions": "Sesje odsłuchowe $S_{\\text{ses}}$", + "sequences_of_sessions": "Sekwencje $S_{\\text{sek}}$", + "train_split": "Zbiór treningowy $S_{\\text{sek | train}}$", + "val_split": "Zbiór walidacyjny $S_{\\text{sek | val}}$", + "test_split": "Zbiór testowy $S_{\\text{sek | test}}$", + "caption": "Liczba rekordów na poszczególnych etapach przygotowania danych dla obu wariantów zbioru Yambda.", # noqa: E501 + "reproducibility_note": ( + "Konfiguracja IMPACT Framework: \\texttt{$display_id}~" + "(\\cref{$appendix_label})." + ), + }, + "en": { + "stage": "Data preparation stage", + "all_events": "All events $E$", + "valid_listen_events": "Valid listen events $E_{\\text{listen}}$", + "listening_sessions": "Listening sessions $S_{\\text{ses}}$", + "sequences_of_sessions": "Sequences $S_{\\text{sek}}$", + "train_split": "Train split $S_{\\text{sek | train}}$", + "val_split": "Validation split $S_{\\text{sek | val}}$", + "test_split": "Test split $S_{\\text{sek | test}}$", + "caption": "The number of records at each stage of data preparation for both variants of the Yambda dataset.", # noqa: E501 + "reproducibility_note": ( + "The IMPACT Framework configuration: \\texttt{$display_id}~" + "(\\cref{$appendix_label})." + ), + }, + } + t = translations[lang] + + table_rows: list[str] = [ + "all_events", + "valid_listen_events", + "listening_sessions", + "sequences_of_sessions", + "train_split", + "val_split", + "test_split", + ] + + rows: list[dict[str, str | int]] = [] + footnotes: list[str] = [] + markers: str = string.ascii_lowercase + + for i, row in enumerate(data_funnel_stats_df.iter_rows(named=True)): + marker = markers[i] + tnote = f"\\tnote{{\\hyperlink{{note:{marker}}}{{{marker}}}}}" + + config_display_id = row[DataFunnelStatsSchema.config_display_id] + rows.append( + { + "dataset": f"{row[DataFunnelStatsSchema.dataset_variant]}{tnote}", + **{header: _format_num(row[header]) for header in table_rows}, + } + ) + + hypertarget = f"\\hypertarget{{note:{marker}}}{{{marker}}}" + note_text = string.Template(t["reproducibility_note"]).substitute( + display_id=config_display_id, + appendix_label=f"appendix:impactconfig:{config_display_id}", + ) + footnotes.append(f"\\item[{hypertarget}]: {note_text}") + + df = pl.DataFrame( + { + t["stage"]: [*[t[header] for header in table_rows]], + **{r["dataset"]: [*[r[header] for header in table_rows]] for r in rows}, + } + ) + + tablenotes_latex = ( + "\\begin{tablenotes}[flushleft]\n" + "\\footnotesize\n" + f"{'\n'.join(footnotes)}\n" + "\\end{tablenotes}\n" + ) + + return ( + df.to_pandas() + .style.hide(axis="index") + # Generate LaTeX table using Pandas Styler + .to_latex( + hrules=True, + environment="threeparttable", # To allow table footnotes + position="H", + position_float="centering", + label="tab:data_funnel_stats", + caption=" ".join(t["caption"].split()), + column_format="lrr", # 'l' for the text, 'r' for the numbers + ) + .replace("\\end{tabular}\n", f"\\end{{tabular}}\n{tablenotes_latex}") + ) + + +def _format_num(n: int) -> str: + """Format integer with space as thousands separator.""" + return f"{n:,}".replace(",", " ") diff --git a/scripts/types.py b/scripts/types.py new file mode 100644 index 0000000..98f149e --- /dev/null +++ b/scripts/types.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from typing import Literal + +type Language = Literal["en", "pl"] +type TableFormat = Literal["latex", "markdown"] diff --git a/uv.lock b/uv.lock index 7ed7950..b861cb1 100644 --- a/uv.lock +++ b/uv.lock @@ -78,6 +78,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "anyio" version = "4.13.0" @@ -158,14 +167,14 @@ wheels = [ [[package]] name = "basedpyright" -version = "1.39.4" +version = "1.39.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodejs-wheel-binaries" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/b0/ab36a25bb4637c6083e88721351f96b30742b84debdea42892b0f0d08cc9/basedpyright-1.39.4.tar.gz", hash = "sha256:e604088a0a808377a91d8ea0c4ef427c1af6fb335529bbe65a055263163d9252", size = 25507140, upload-time = "2026-05-11T11:27:04.631Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/4b/c1f4e211e50389304d6af32b9280e026a7133e3ad59bbdf8f7a3250f8bee/basedpyright-1.39.9.tar.gz", hash = "sha256:32cbea5fc8273e89df3db20daea56cb7286e419ccdfdc479c64759d2dc071901", size = 24412216, upload-time = "2026-06-27T02:19:49.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/39/9c256405e8d2a974b9fb819bd9c8a44ca4ba0f335c8ebb8d6961cee5f0c0/basedpyright-1.39.4-py3-none-any.whl", hash = "sha256:3cefd334f101fa4125c4ced4db6ca620267b57536ba0c3f8a7ae8b13c4c11f7a", size = 12420117, upload-time = "2026-05-11T11:26:59.829Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d4/e1fa108710d0498a18c77b1e13897f31eab47c69aa8cfe2d2a4df746541e/basedpyright-1.39.9-py3-none-any.whl", hash = "sha256:6b0837b9eba972c71895167ab9b127e6afdbc17abc92312e3f8d15ca82a5611c", size = 13374276, upload-time = "2026-06-27T02:19:54.431Z" }, ] [[package]] @@ -588,7 +597,7 @@ wheels = [ [[package]] name = "impact" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "altair", extra = ["save"] }, @@ -596,6 +605,7 @@ dependencies = [ { name = "huggingface-hub" }, { name = "jinja2" }, { name = "pandas" }, + { name = "pandera", extra = ["polars"] }, { name = "polars", extra = ["rt64"] }, { name = "pyarrow" }, { name = "python-dotenv" }, @@ -637,6 +647,7 @@ requires-dist = [ { name = "huggingface-hub", specifier = ">=1.15.0" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "pandas", specifier = ">=3.0.3" }, + { name = "pandera", extras = ["polars"], specifier = ">=0.32.1" }, { name = "polars", extras = ["rt64"], specifier = ">=1.40.1" }, { name = "pyarrow", specifier = ">=24.0.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, @@ -1551,6 +1562,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/2f/f91e4eee21585ff548e83358332d5632ee49f6b2dcd96cb5dca4e0468951/pandas_stubs-3.0.0.260204-py3-none-any.whl", hash = "sha256:5ab9e4d55a6e2752e9720828564af40d48c4f709e6a2c69b743014a6fcb6c241", size = 168540, upload-time = "2026-02-04T15:17:15.615Z" }, ] +[[package]] +name = "pandera" +version = "0.32.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pydantic" }, + { name = "typeguard" }, + { name = "typing-extensions" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/74/7ef34611e49990b7e630dcf02c2225d76c2e328ec2804186c2b9c52e916b/pandera-0.32.1.tar.gz", hash = "sha256:72ecd74226847abf0f0437c05f7f10cc8368e306d88a2acc78fa93762c5a0a02", size = 875349, upload-time = "2026-06-29T16:01:48.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/d0/411c82285a7586e97326020f6b5ecbc2f2ffcbef72aa108c897de1b0a540/pandera-0.32.1-py3-none-any.whl", hash = "sha256:1a17a3ffa906174d19207715f4f082ec3db3709647927ad8c095c147d74d8454", size = 447840, upload-time = "2026-06-29T16:01:46.877Z" }, +] + +[package.optional-dependencies] +polars = [ + { name = "polars" }, +] + [[package]] name = "parso" version = "0.8.7" @@ -1749,6 +1781,96 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -2410,6 +2532,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/68/fa86e5a39608000f645535b2c124920126327ab731f8c4fafd5b07ff8d4b/triton-3.7.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce061073102714b725f3660ec6939d94a1da7984b3aa99c921417cae273672f5", size = 201546766, upload-time = "2026-05-07T18:46:42.088Z" }, ] +[[package]] +name = "typeguard" +version = "4.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1c/dfba5c4633cafc4c701f237d2ba63b416805047fd6d96aab4cfc40969f98/typeguard-4.5.2.tar.gz", hash = "sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423", size = 80240, upload-time = "2026-05-14T12:59:40.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl", hash = "sha256:fcf9de18bd945cdb4c7b996e12b4c51ce83f92f191314a6d7cf1739586ec98cf", size = 36748, upload-time = "2026-05-14T12:59:39.473Z" }, +] + [[package]] name = "typer" version = "0.25.1" @@ -2476,6 +2610,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2026.2" From 7e86fce14815d9eaadcbee62c7580ee139e62de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20G=C5=82owacz?= Date: Fri, 31 Jul 2026 17:28:23 +0200 Subject: [PATCH 09/12] [#28] fix(scripts): adjust latex table renderer to properly format output LaTeX code for the data funnel stats table (positioning and number format) --- scripts/tables_render_latex.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/tables_render_latex.py b/scripts/tables_render_latex.py index 619098e..3169a02 100644 --- a/scripts/tables_render_latex.py +++ b/scripts/tables_render_latex.py @@ -109,7 +109,7 @@ def render_data_funnel_stats( .to_latex( hrules=True, environment="threeparttable", # To allow table footnotes - position="H", + position="!htb", position_float="centering", label="tab:data_funnel_stats", caption=" ".join(t["caption"].split()), @@ -121,4 +121,4 @@ def render_data_funnel_stats( def _format_num(n: int) -> str: """Format integer with space as thousands separator.""" - return f"{n:,}".replace(",", " ") + return f"{n:,}".replace(",", "\\,") From d748adb419a6932bd27ac0c71f5394202cbf8c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20G=C5=82owacz?= Date: Sat, 24 Oct 2026 14:53:37 +0200 Subject: [PATCH 10/12] [#28] feat(scripts): Align data funnel stats table creation with the final form of it used in the thesis --- scripts/cli.py | 27 ++++++++---------- scripts/tables_data.py | 5 ++-- scripts/tables_render_latex.py | 51 ++++++---------------------------- tests/__init__.py | 0 4 files changed, 22 insertions(+), 61 deletions(-) delete mode 100644 tests/__init__.py diff --git a/scripts/cli.py b/scripts/cli.py index 85ca211..fca5901 100644 --- a/scripts/cli.py +++ b/scripts/cli.py @@ -13,8 +13,6 @@ from .tables_data import compute_data_funnel_stats_for_variants if TYPE_CHECKING: - from collections.abc import Callable - from impact.config import ImpactConfig from .types import Language, TableFormat @@ -24,7 +22,7 @@ @click.group() def cli() -> None: - """Main script's CLI entry point.""" + """CLI entry point for the scripts.""" pass @@ -63,8 +61,8 @@ def tables() -> None: type=click.Path(file_okay=False, path_type=Path), default=None, help=""" - Output directory for the LaTeX code and corresponding ImpactConfig YAML dumps. - Defaults to project root / 'reports' / 'tables' / 'data-funnel-stats'. + Output directory for the rendered table code and corresponding ImpactConfig YAML + dumps. Defaults to project root / 'reports' / 'tables' / 'data-funnel-stats'. """, ) @click.option( @@ -106,9 +104,7 @@ def generate_data_funnel_stats_table( processed_data_dir: Path | None = None, ) -> None: """Generate a table of record counts across data preparation stages.""" - configure_logging( - level=logging.getLevelNamesMapping()[log_level], - ) # Configure logging without piping logs to files + configure_logging(level=logging.getLevelNamesMapping()[log_level]) out_dir = out_dir or PROJECT_ROOT / "reports" / "tables" / "data-funnel-stats" out_dir.mkdir(parents=True, exist_ok=True) @@ -120,20 +116,19 @@ def generate_data_funnel_stats_table( processed_data_dir=processed_data_dir or PROJECT_ROOT / "data" / "processed", ) - renderer_ext_by_fmt: dict[TableFormat, tuple[Callable[..., str], str]] = { - "latex": (tables_render_latex.render_data_funnel_stats, "tex"), - # "markdown": tables_render_markdown.render_data_funnel_stats, - } - - rendered_table = renderer_ext_by_fmt[fmt][0](data_df, lang.lower()) - ext = renderer_ext_by_fmt[fmt][1] + match fmt: + case "latex": + rendered_table = tables_render_latex.render_data_funnel_stats(data_df, lang) + ext = "tex" + case _: + raise NotImplementedError for display_id, config in impact_config_by_display_id.items(): config_dump_path = out_dir / f"{display_id}.yaml" config.write_yaml(config_dump_path) click.echo(f"Config dumped to {config_dump_path.as_posix()}") - out_file = out_dir / f"data_funnel_stats.{ext}" + out_file = out_dir / f"data_funnel_stats-{lang}.{ext}" _ = out_file.write_text(rendered_table) click.echo(f"Table saved at {out_file.as_posix()}") diff --git a/scripts/tables_data.py b/scripts/tables_data.py index 6926fe3..03723a7 100644 --- a/scripts/tables_data.py +++ b/scripts/tables_data.py @@ -1,7 +1,7 @@ from __future__ import annotations import dataclasses -from typing import TYPE_CHECKING, Final, NamedTuple, cast +from typing import TYPE_CHECKING, Final, cast import pandera.polars as pa import polars as pl @@ -36,7 +36,8 @@ class DataFunnelStatsSchema(pa.DataFrameModel): test_split: Series[pl.Int64] = pa.Field() -class DataFunnelVariant(NamedTuple): +@dataclasses.dataclass(frozen=True) +class DataFunnelVariant: """Dataset variant to be included in data funnel stats table, e.g. Yambda-50M.""" dataset_size: DatasetSize diff --git a/scripts/tables_render_latex.py b/scripts/tables_render_latex.py index 3169a02..71d5c76 100644 --- a/scripts/tables_render_latex.py +++ b/scripts/tables_render_latex.py @@ -1,8 +1,5 @@ -"""Module for creating tables in LaTeX format that are presented in the thesis.""" - from __future__ import annotations -import string from typing import TYPE_CHECKING import polars as pl @@ -32,10 +29,6 @@ def render_data_funnel_stats( "val_split": "Zbiór walidacyjny $S_{\\text{sek | val}}$", "test_split": "Zbiór testowy $S_{\\text{sek | test}}$", "caption": "Liczba rekordów na poszczególnych etapach przygotowania danych dla obu wariantów zbioru Yambda.", # noqa: E501 - "reproducibility_note": ( - "Konfiguracja IMPACT Framework: \\texttt{$display_id}~" - "(\\cref{$appendix_label})." - ), }, "en": { "stage": "Data preparation stage", @@ -47,10 +40,6 @@ def render_data_funnel_stats( "val_split": "Validation split $S_{\\text{sek | val}}$", "test_split": "Test split $S_{\\text{sek | test}}$", "caption": "The number of records at each stage of data preparation for both variants of the Yambda dataset.", # noqa: E501 - "reproducibility_note": ( - "The IMPACT Framework configuration: \\texttt{$display_id}~" - "(\\cref{$appendix_label})." - ), }, } t = translations[lang] @@ -65,29 +54,13 @@ def render_data_funnel_stats( "test_split", ] - rows: list[dict[str, str | int]] = [] - footnotes: list[str] = [] - markers: str = string.ascii_lowercase - - for i, row in enumerate(data_funnel_stats_df.iter_rows(named=True)): - marker = markers[i] - tnote = f"\\tnote{{\\hyperlink{{note:{marker}}}{{{marker}}}}}" - - config_display_id = row[DataFunnelStatsSchema.config_display_id] - rows.append( - { - "dataset": f"{row[DataFunnelStatsSchema.dataset_variant]}{tnote}", - **{header: _format_num(row[header]) for header in table_rows}, - } - ) - - hypertarget = f"\\hypertarget{{note:{marker}}}{{{marker}}}" - note_text = string.Template(t["reproducibility_note"]).substitute( - display_id=config_display_id, - appendix_label=f"appendix:impactconfig:{config_display_id}", - ) - footnotes.append(f"\\item[{hypertarget}]: {note_text}") - + rows: list[dict[str, str | int]] = [ + { + "dataset": row[DataFunnelStatsSchema.dataset_variant], + **{header: _format_num(row[header]) for header in table_rows}, + } + for row in data_funnel_stats_df.iter_rows(named=True) + ] df = pl.DataFrame( { t["stage"]: [*[t[header] for header in table_rows]], @@ -95,27 +68,19 @@ def render_data_funnel_stats( } ) - tablenotes_latex = ( - "\\begin{tablenotes}[flushleft]\n" - "\\footnotesize\n" - f"{'\n'.join(footnotes)}\n" - "\\end{tablenotes}\n" - ) - return ( df.to_pandas() .style.hide(axis="index") # Generate LaTeX table using Pandas Styler .to_latex( hrules=True, - environment="threeparttable", # To allow table footnotes + environment="table", position="!htb", position_float="centering", label="tab:data_funnel_stats", caption=" ".join(t["caption"].split()), column_format="lrr", # 'l' for the text, 'r' for the numbers ) - .replace("\\end{tabular}\n", f"\\end{{tabular}}\n{tablenotes_latex}") ) diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 From 3931b848d9fba9bc3e787727fe828ba694f86a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20G=C5=82owacz?= Date: Sat, 24 Oct 2026 15:10:34 +0200 Subject: [PATCH 11/12] [#28] feat(scripts): Add markdown table renderer for data funnel stats table --- scripts/cli.py | 11 +++-- scripts/tables_render_markdown.py | 78 +++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 scripts/tables_render_markdown.py diff --git a/scripts/cli.py b/scripts/cli.py index fca5901..225274a 100644 --- a/scripts/cli.py +++ b/scripts/cli.py @@ -9,7 +9,7 @@ from impact.cli import inject_impact_config from impact.logger import configure_logging -from . import tables_render_latex +from . import tables_render_latex, tables_render_markdown from .tables_data import compute_data_funnel_stats_for_variants if TYPE_CHECKING: @@ -118,10 +118,11 @@ def generate_data_funnel_stats_table( match fmt: case "latex": - rendered_table = tables_render_latex.render_data_funnel_stats(data_df, lang) + table = tables_render_latex.render_data_funnel_stats(data_df, lang) ext = "tex" - case _: - raise NotImplementedError + case "markdown": + table = tables_render_markdown.render_data_funnel_stats(data_df, lang) + ext = "md" for display_id, config in impact_config_by_display_id.items(): config_dump_path = out_dir / f"{display_id}.yaml" @@ -129,7 +130,7 @@ def generate_data_funnel_stats_table( click.echo(f"Config dumped to {config_dump_path.as_posix()}") out_file = out_dir / f"data_funnel_stats-{lang}.{ext}" - _ = out_file.write_text(rendered_table) + _ = out_file.write_text(table) click.echo(f"Table saved at {out_file.as_posix()}") diff --git a/scripts/tables_render_markdown.py b/scripts/tables_render_markdown.py new file mode 100644 index 0000000..5d7ab56 --- /dev/null +++ b/scripts/tables_render_markdown.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import polars as pl +import structlog + +from .tables_data import DataFunnelStatsSchema + +if TYPE_CHECKING: + from .types import Language + +logger = structlog.stdlib.get_logger(__name__) + + +def render_data_funnel_stats( + data_funnel_stats_df: pl.DataFrame, + lang: Language = "en", +) -> str: + """Generate a Markdown table of record counts across data preparation stages.""" + translations: dict[Language, dict[str, str]] = { + "pl": { + "stage": "Etap przygotowania danych", + "all_events": "Wszystkie interakcje **E**", + "valid_listen_events": "Odsłuchy **E_listen**", + "listening_sessions": "Sesje odsłuchowe **S_ses**", + "sequences_of_sessions": "Sekwencje **S_sek**", + "train_split": "Zbiór treningowy **S_sek_train**", + "val_split": "Zbiór walidacyjny **S_sek_val**", + "test_split": "Zbiór testowy **S_sek_test**", + }, + "en": { + "stage": "Data preparation stage", + "all_events": "All events **E**", + "valid_listen_events": "Valid listen events **E_listen**", + "listening_sessions": "Listening sessions **S_ses**", + "sequences_of_sessions": "Sequences **S_sek**", + "train_split": "Train split **S_sek_train**", + "val_split": "Validation split **S_sek_val**", + "test_split": "Test split **S_sek_test**", + }, + } + t = translations[lang] + + table_rows: list[str] = [ + "all_events", + "valid_listen_events", + "listening_sessions", + "sequences_of_sessions", + "train_split", + "val_split", + "test_split", + ] + + rows: list[dict[str, str | int]] = [ + { + "dataset": row[DataFunnelStatsSchema.dataset_variant], + **{header: _format_num(row[header]) for header in table_rows}, + } + for row in data_funnel_stats_df.iter_rows(named=True) + ] + df = pl.DataFrame( + { + t["stage"]: [*[t[header] for header in table_rows]], + **{r["dataset"]: [*[r[header] for header in table_rows]] for r in rows}, + } + ) + with pl.Config( + tbl_formatting="MARKDOWN", + tbl_hide_column_data_types=True, + tbl_hide_dataframe_shape=True, + fmt_str_lengths=99, + ): + return str(df) + + +def _format_num(n: int) -> str: + return f"{n:,}".replace(",", " ") From d2a1080fae5edf7f2e4f1d6cf1905bf709c447ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20G=C5=82owacz?= Date: Sat, 7 Nov 2026 19:19:50 +0100 Subject: [PATCH 12/12] [#28] feat(scripts): Refactor plots rendering workflow --- .gitignore | 1 + justfile | 9 +- scripts/cli.py | 184 ++++++++- scripts/plots.py | 657 ------------------------------ scripts/plots_data.py | 100 +++++ scripts/plots_render.py | 272 +++++++++++++ scripts/tables_render_latex.py | 4 +- scripts/tables_render_markdown.py | 4 +- scripts/types.py | 1 + 9 files changed, 563 insertions(+), 669 deletions(-) delete mode 100644 scripts/plots.py create mode 100644 scripts/plots_data.py create mode 100644 scripts/plots_render.py diff --git a/.gitignore b/.gitignore index d333a7d..c51babc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ notebooks/*.ipynb !notebooks/kaggle_grid_search.ipynb !notebooks/kaggle_sasrec_training.ipynb reports +final-artefacts # Created by https://www.toptal.com/developers/gitignore/api/python # Edit at https://www.toptal.com/developers/gitignore?templates=python diff --git a/justfile b/justfile index 2555fb3..78877ab 100644 --- a/justfile +++ b/justfile @@ -11,8 +11,8 @@ install *ARGS: _uv uv sync --frozen {{ARGS}} # Synchronize dependencies -sync: _uv - uv sync +sync *ARGS: _uv + uv sync {{ARGS}} # Check code style and formatting (no fixes applied) lint: _uv @@ -25,7 +25,7 @@ typecheck: _uv uv run basedpyright --stats # Run the IMPACT CLI -run *ARGS: _uv +impact *ARGS: _uv uv run cli {{ARGS}} # Run the scripts/plots.py CLI @@ -36,6 +36,9 @@ thesis-plots *ARGS: _uv thesis-tables *ARGS: _uv uv run scripts/tables.py {{ARGS}} +table name *ARGS: _uv + uv run python -m scripts.cli tables {{name}} {{ARGS}} + # Recursively remove cache and .pyc/.pyo files cleanup: @find . -regextype posix-extended -regex '.*(__pycache__|\.py[co])$$' -delete diff --git a/scripts/cli.py b/scripts/cli.py index 225274a..14ae146 100644 --- a/scripts/cli.py +++ b/scripts/cli.py @@ -6,16 +6,21 @@ import click -from impact.cli import inject_impact_config +from impact.cli import ( + inject_impact_config, + inject_impact_config_from_checkpoint, + inject_impact_config_overrides, +) from impact.logger import configure_logging -from . import tables_render_latex, tables_render_markdown +from . import plots_render, tables_render_latex, tables_render_markdown +from .plots_data import compute_alpha_curve_plot_data from .tables_data import compute_data_funnel_stats_for_variants if TYPE_CHECKING: from impact.config import ImpactConfig - from .types import Language, TableFormat + from .types import Language, PlotFormat, TableFormat PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[1] @@ -32,20 +37,26 @@ def tables() -> None: pass +@cli.group(name="plots") +def plots() -> None: + """Commands for generating plots (used in thesis/documentation).""" + pass + + @tables.command(name="data-funnel-stats") @click.option( "--log-level", - type=click.Choice(choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), + type=click.Choice(list(logging.getLevelNamesMapping()), case_sensitive=False), default="WARNING", show_default=True, - help="Logging level.", + help="Logging verbosity level.", ) @click.option( "--lang", type=click.Choice(["pl", "en"], case_sensitive=False), default="en", show_default=True, - help="Language for the table headers and textual content.", + help="Language for axis labels and annotations.", ) @click.option( "--format", @@ -95,7 +106,7 @@ def tables() -> None: @inject_impact_config def generate_data_funnel_stats_table( impact_config: ImpactConfig, - log_level: str = "INFO", + log_level: str = "WARNING", lang: Language = "en", fmt: TableFormat = "latex", out_dir: Path | None = None, @@ -134,5 +145,164 @@ def generate_data_funnel_stats_table( click.echo(f"Table saved at {out_file.as_posix()}") +@plots.command(name="alpha-curve") +@click.option( + "--log-level", + type=click.Choice(list(logging.getLevelNamesMapping()), case_sensitive=False), + default="WARNING", + show_default=True, + help="Logging verbosity level.", +) +@click.option( + "--lang", + type=click.Choice(["pl", "en"], case_sensitive=False), + default="en", + show_default=True, + help="Language for axis labels and annotations.", +) +@click.option( + "--out-dir", + "-o", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help=""" + Output directory for the generated plots and corresponding ImpactConfig YAML + dumps. Defaults to project root / 'reports' / 'plots' / 'alpha-curve'. + """, +) +@click.option( + "--raw-data-dir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help=""" + Root directory for raw datasets (the original, immutable data dump). + Defaults to project root / 'data' / 'raw'. + """, +) +@click.option( + "--interim-data-dir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help=""" + Root directory for interim data (intermediate data that has been transformed). + Defaults to project root / 'data' / 'interim'. + """, +) +@click.option( + "--processed-data-dir", + type=click.Path(file_okay=False, path_type=Path), + default=None, + help=""" + Root directory for processed data (the final datasets for model training and + evaluation). Defaults to project root / 'data' / 'processed'. + """, +) +@click.option( + "--alpha-start", + type=float, + default=0.0, + show_default=True, + help="Lower bound of the alpha sweep range (inclusive).", +) +@click.option( + "--alpha-stop", + type=float, + default=1.0, + show_default=True, + help="Upper bound of the alpha sweep range (inclusive).", +) +@click.option( + "--alpha-step", + type=float, + default=0.01, + show_default=True, + help="Step size between consecutive alpha values in the sweep.", +) +@click.option( + "--metrics-at-k", + type=int, + default=10, + show_default=True, + help="Cutoff level k for the metrics collected during the alpha sweep.", +) +@click.option( + "--no-title", + is_flag=True, + default=False, + help="Suppress the chart title (e.g. when the figure will be captioned in LaTeX).", +) +@click.option( + "--format", + "-f", + "formats", + type=click.Choice(("svg", "pdf", "png"), case_sensitive=False), + multiple=True, + default=("svg", "pdf"), + show_default=True, + help="Output format(s). Can be specified multiple times: -f svg -f pdf.", +) +@inject_impact_config_from_checkpoint +@inject_impact_config_overrides("actr", "eval", "system") +def plot_alpha_curve( + impact_config: ImpactConfig, + sasrec_checkpoint_path: Path, + log_level: str = "WARNING", + lang: Language = "en", + out_dir: Path | None = None, + raw_data_dir: Path | None = None, + interim_data_dir: Path | None = None, + processed_data_dir: Path | None = None, + alpha_start: float = 0.0, + alpha_stop: float = 1.0, + alpha_step: float = 0.01, + metrics_at_k: int = 10, + no_title: bool = False, + formats: tuple[PlotFormat, ...] = ("svg", "pdf"), +) -> None: + """Plot IMPACT evaluation metrics as a function of alpha.""" + configure_logging(level=logging.getLevelNamesMapping()[log_level]) + + out_dir = out_dir or PROJECT_ROOT / "reports" / "plots" / "alpha-curve" + out_dir.mkdir(parents=True, exist_ok=True) + + config_identifier_suffix = "-".join( + [ + str(alpha_start).replace(".", "_"), + str(alpha_stop).replace(".", "_"), + str(alpha_step).replace(".", "_"), + str(metrics_at_k).replace(".", "_"), + ] + ) + config_identifier = f"{impact_config.display_id}-{config_identifier_suffix}" + + alpha_curve_df = compute_alpha_curve_plot_data( + impact_config=impact_config, + sasrec_checkpoint_path=sasrec_checkpoint_path, + raw_data_dir=raw_data_dir or PROJECT_ROOT / "data" / "raw", + interim_data_dir=interim_data_dir or PROJECT_ROOT / "data" / "interim", + processed_data_dir=processed_data_dir or PROJECT_ROOT / "data" / "processed", + alpha_start=alpha_start, + alpha_stop=alpha_stop, + alpha_step=alpha_step, + metrics_at_k=metrics_at_k, + ) + out_files = plots_render.render_alpha_curve_plot( + alpha_curve_df=alpha_curve_df, + alpha_start=alpha_start, + alpha_stop=alpha_stop, + out_dir=out_dir, + out_filename=f"alpha-curve-{lang}-{config_identifier}", + formats=formats, + lang=lang, + no_title=no_title, + ) + for out_file in out_files: + click.echo(f"Saved chart at {out_file.as_posix()}") + + config_dump_path = out_dir / f"{config_identifier}.yaml" + impact_config.write_yaml(config_dump_path) + click.echo(f"Config dumped to {config_dump_path.as_posix()}") + + if __name__ == "__main__": cli() diff --git a/scripts/plots.py b/scripts/plots.py deleted file mode 100644 index f79ca3c..0000000 --- a/scripts/plots.py +++ /dev/null @@ -1,657 +0,0 @@ -"""Module for creating plots that will be presented in the thesis.""" - -import dataclasses -import datetime -import logging -from pathlib import Path -from typing import Any, Literal, NamedTuple, cast - -import altair as alt -import click -import dotenv -import numpy as np -import polars as pl -import structlog - -from impact.cli import ( - inject_impact_config_from_checkpoint, - inject_impact_config_overrides, -) -from impact.config import ImpactConfig -from impact.logger import configure_logging -from impact.pipelines import DataPipeline, ImpactEvaluationPipeline -from impact.utils import seed_everything - -logger = structlog.stdlib.get_logger(__name__) - - -class OkabeItoPalette(NamedTuple): - """Okabe-Ito palette as a named tuple of hex color strings. - - This color palette is colorblind-safe and prints well in grayscale. Encapsulating - it in a NamedTuple makes colors accessible by name rather than by index. - """ - - orange: str - sky_blue: str - bluish_green: str - yellow: str - blue: str - vermillion: str - reddish_purple: str - black: str - - -# General constants -PROJECT_ROOT: Path = Path(__file__).resolve().parents[1] - -# Chart theme styling -FONT: str = "Libertinus Serif" # Match the serif font used in the thesis -FONT_SIZE_BASE = 12 # Match the 12pt font used in the thesis -FONT_SIZE_SMALL = 10 -FONT_SIZE_TINY = 8 -COLOR_INK = "#1A1A1A" # Labels, ticks, axis domains -COLOR_GRID = "#E0E0E0" # Passive guides - gridlines, legend borders, etc. -OKABE_ITO_PALETTE = OkabeItoPalette( - orange="#E69F00", - sky_blue="#56B4E9", - bluish_green="#009E73", - yellow="#F0E442", - blue="#0072B2", - vermillion="#D55E00", - reddish_purple="#CC79A7", - black="#000000", -) - - -@alt.theme.register("thesis_theme", enable=True) -def thesis_theme() -> alt.theme.ThemeConfig: - """Register and enable an Altair theme for publication-quality visualizations.""" - return { - "config": { - # -- Canvas -- - "view": { - "strokeWidth": 0, # No border around plot area - "continuousWidth": 400, - "continuousHeight": 250, # Keep the aspect ratio of 16:10 (approx.) - }, - "background": "white", - # -- Typography -- - "title": { - "font": FONT, - "fontSize": FONT_SIZE_BASE, - "fontWeight": "bold", - "color": COLOR_INK, - "anchor": "middle", - }, - # -- Axes -- - "axis": { - "labelFont": FONT, - "titleFont": FONT, - "labelFontSize": FONT_SIZE_SMALL, - "titleFontSize": FONT_SIZE_BASE, - "titleFontWeight": "normal", # Let the thesis captions catch the eye - "labelColor": COLOR_INK, - "titleColor": COLOR_INK, - "domainColor": COLOR_INK, - "domainWidth": 1, - "tickColor": COLOR_INK, - "tickWidth": 1, - "gridColor": COLOR_GRID, - "gridWidth": 0.5, - "gridDash": [4, 4], - }, - "axisX": {"grid": False}, # Disable vertical gridlines - "axisY": {"grid": True}, # Keep the horizontal though - # -- Legend -- - "legend": { - "labelFont": FONT, - "titleFont": FONT, - "labelFontSize": FONT_SIZE_SMALL, - "titleFontSize": FONT_SIZE_SMALL, - "titleFontWeight": "normal", # Let the thesis captions catch the eye - "orient": "top-right", - "fillColor": "white", - "strokeColor": COLOR_GRID, - "padding": 5, - "cornerRadius": 2, - }, - # -- Header -- - "header": { - "labelFont": FONT, - "titleFont": FONT, - }, - # -- Marks -- - "line": {"strokeWidth": 1.6}, - "point": {"size": 35, "filled": True}, - "bar": {"fill": OKABE_ITO_PALETTE.blue}, # pyright: ignore[reportReturnType] - # -- Color scale default -- - "range": { - "category": OKABE_ITO_PALETTE, - "ordinal": OKABE_ITO_PALETTE, - }, - } - } - - -@click.group() -def cli() -> None: - """CLI entrypoint for the functions defined in this module.""" - pass - - -@cli.command(name="plot-alpha-curve") -@click.option( - "--log-level", - type=click.Choice(list(logging.getLevelNamesMapping()), case_sensitive=False), - default="WARNING", - show_default=True, - help="Logging verbosity level.", -) -@click.option( - "--lang", - type=click.Choice(["pl", "en"], case_sensitive=False), - default="en", - show_default=True, - help="Language for axis labels and annotations.", -) -@click.option( - "--raw-data-dir", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help=""" - Base directory for raw data (the original, immutable data dump). - Defaults to /data/raw. - """, -) -@click.option( - "--interim-data-dir", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help=""" - Base directory for interim data (intermediate data that has been transformed). - Defaults to /data/interim. - """, -) -@click.option( - "--processed-data-dir", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help=""" - Base directory for processed data (the final, canonical datasets for modeling). - Defaults to /data/processed. - """, -) -@click.option( - "--alpha-start", - type=float, - default=0.0, - show_default=True, - help="Lower bound of the alpha sweep range (inclusive).", -) -@click.option( - "--alpha-stop", - type=float, - default=1.0, - show_default=True, - help="Upper bound of the alpha sweep range (inclusive).", -) -@click.option( - "--alpha-step", - type=float, - default=0.01, - show_default=True, - help="Step size between consecutive alpha values in the sweep.", -) -@click.option( - "--metrics-at-k", - type=int, - default=10, - show_default=True, - help="Cutoff level k for the metrics collected during the alpha sweep.", -) -@click.option( - "--out-dir", - "-o", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help=""" - Output directory for the generated plots. - Defaults to /reports/plots/alpha-curve. - """, -) -@click.option( - "--out-filename", - type=str, - default=None, - help=""" - Base filename (without extension) for the saved plots. - Defaults to a UTC timestamp: alpha-curve_YYYY-MM-DD_HH-MM-SS. - """, -) -@click.option( - "--no-title", - is_flag=True, - default=False, - help="Suppress the chart title (e.g. when the figure will be captioned in LaTeX).", -) -@click.option( - "--format", - "-f", - "formats", - type=click.Choice(("svg", "pdf", "png"), case_sensitive=False), - multiple=True, - default=("svg", "pdf"), - show_default=True, - help="Output format(s). Can be specified multiple times: -f svg -f pdf.", -) -@click.option( - "--png-scale", - type=float, - default=3.0, - show_default=True, - help="Scale factor for PNG export. 3.0 is approx. 216 PPI - suitable for print.", -) -@inject_impact_config_from_checkpoint -@inject_impact_config_overrides("actr", "eval", "system") -def plot_alpha_curve( - impact_config: ImpactConfig, - sasrec_checkpoint_path: Path, - log_level: str = "WARNING", - lang: str = "en", - raw_data_dir: Path | None = None, - interim_data_dir: Path | None = None, - processed_data_dir: Path | None = None, - alpha_start: float = 0.0, - alpha_stop: float = 1.0, - alpha_step: float = 0.01, - metrics_at_k: int = 10, - out_dir: Path | None = None, - out_filename: str | None = None, - no_title: bool = False, - formats: tuple[Literal["svg", "pdf", "png"], ...] = ("svg", "pdf"), - png_scale: float = 3.0, -) -> None: - """Plot IMPACT evaluation metrics as a function of alpha. - - Alpha controls the relative contribution of SASRec and ACT-R in the final - late-fusion score (trade-off). - """ - raw_data_dir = raw_data_dir or PROJECT_ROOT / "data" / "raw" - interim_data_dir = interim_data_dir or PROJECT_ROOT / "data" / "interim" - processed_data_dir = processed_data_dir or PROJECT_ROOT / "data" / "processed" - out_dir = out_dir or PROJECT_ROOT / "reports" / "plots" / "alpha-curve" - out_dir.mkdir(parents=True, exist_ok=True) - - timestamp = datetime.datetime.now(datetime.UTC) - out_filename = ( - out_filename or f"alpha-curve_{timestamp.strftime('%Y-%m-%d_%H-%M-%S')}" - ) - - # Configure logging without piping logs to files - configure_logging( - level=logging.getLevelNamesMapping()[log_level], - ) - - # Load env configuration (e.g. HuggingFace API token) - logger.info("Attempting to load env variables from .env file") - _ = dotenv.load_dotenv() - - # Set seed for reproducibility - seed_everything(impact_config.system.seed) - - data_pipeline = DataPipeline( - raw_dir=raw_data_dir, - interim_dir=interim_data_dir, - processed_dir=processed_data_dir, - config=impact_config, - ) - - logger.info("Fetching listening events data...") - listens_lf = data_pipeline.get_listen_events() - - logger.info("Fetching train/val/test splits...") - splits, item_index = data_pipeline.get_splits() - - logger.info("Tensorizing splits...") - tensorized_splits = data_pipeline.tensorize(splits) - - alphas = np.round( - a=np.arange(alpha_start, alpha_stop + alpha_step / 2, alpha_step), - decimals=2, - ) - results: list[dict[str, float]] = [] - - for alpha in alphas: - eval_config = dataclasses.replace( - impact_config, - eval=dataclasses.replace( - impact_config.eval, - alpha=float(alpha), - k_cutoffs=(metrics_at_k,), - ), - ) - - logger.info(f"Running IMPACT evaluation for {alpha=}...") - eval_pipeline = ImpactEvaluationPipeline(eval_config, item_index, listens_lf) - metrics: dict[str, float] = eval_pipeline.evaluate( - split_partition=tensorized_splits.val, - sasrec_state_dict_path=sasrec_checkpoint_path, - ) - - row = {"alpha": float(alpha), **metrics} - results.append(row) - - # -- Labels per language -- - labels: dict[str, dict[str, str]] = { - "en": { - "x_title": "α (0 = pure ACT-R, 1 = pure SASRec)", # noqa: RUF001 - "y_left_title": "NDCG@10 (Overall & Repetition)", - "y_right_title": "NDCG@10 (Exploration)", - "title": "Effect of α on IMPACT Metrics", # noqa: RUF001 - "ndcg_at_10": "NDCG@10 (Overall)", - "ndcg_local_rep_at_10": "Local Repetition", - "ndcg_global_rep_at_10": "Global Repetition", - "ndcg_local_exp_at_10": "Local Exploration", - "ndcg_global_exp_at_10": "Global Exploration", - }, - "pl": { - "x_title": "α (0 = wyłącznie ACT-R, 1 = wyłącznie SASRec)", # noqa: RUF001 - "y_left_title": "NDCG@10 (ogólne i powtarzalność)", - "y_right_title": "NDCG@10 (eksploracja)", - "title": "Wpływ parametru α na metryki IMPACT", # noqa: RUF001 - "ndcg_at_10": "NDCG@10 (ogólne)", - "ndcg_local_rep_at_10": "Powtarzalność lokalna", - "ndcg_global_rep_at_10": "Powtarzalność globalna", - "ndcg_local_exp_at_10": "Eksploracja lokalna", - "ndcg_global_exp_at_10": "Eksploracja globalna", - }, - } - lbl = labels[lang] - - # -- Plot data -- - df_plot = ( - pl.DataFrame(results) - .unpivot( - on=[r for r in results[0] if r != "alpha"], - index="alpha", - variable_name="metric", - value_name="score", - ) - .filter(pl.col("metric").str.contains("(?i)ndcg")) # only NDCG - .with_columns(pl.col("metric").replace(labels[lang])) - ) - - # -- Subplots specification -- - left_subplot_metrics_to_colors: dict[str, str] = { - lbl[metric]: color - for metric, color in { - "ndcg_at_10": OKABE_ITO_PALETTE.black, - "ndcg_local_rep_at_10": OKABE_ITO_PALETTE.blue, - "ndcg_global_rep_at_10": OKABE_ITO_PALETTE.vermillion, - }.items() - } - right_subplot_metrics_to_colors: dict[str, str] = { - lbl[metric]: color - for metric, color in { - "ndcg_local_exp_at_10": OKABE_ITO_PALETTE.orange, - "ndcg_global_exp_at_10": OKABE_ITO_PALETTE.bluish_green, - }.items() - } - - # -- Shared x-axis -- - base_x = alt.X( - "alpha:Q", - title=lbl["x_title"], - scale=alt.Scale(domain=[alpha_start, alpha_stop]), - axis=alt.Axis(tickCount=5, grid=False), - ) - - def _create_subplot( - subplot_df: pl.DataFrame, - y_title: str, - colors: list[str], - domain: list[str], - ) -> alt.LayerChart: - base = alt.Chart(subplot_df) - - lines = base.mark_line().encode( - x=base_x, - y=alt.Y("score:Q", title=y_title, scale=alt.Scale(zero=False)), - color=alt.Color( - "metric:N", - scale=alt.Scale(domain=domain, range=colors), - legend=alt.Legend(title=None), - ), - tooltip=["alpha:Q", "metric:N", alt.Tooltip("score:Q", format=".4f")], - ) - - points = base.mark_point(filled=True).encode( - x=base_x, - y=alt.Y("score:Q"), - color=alt.Color("metric:N"), - ) - - text_labels = ( - base.mark_text( - align="left", - baseline="middle", - dx=6, # offset to the right - dy=-6, # offset up - fontSize=FONT_SIZE_TINY, - opacity=0.6, - ) - .encode( - x=base_x, - y=alt.Y("score:Q"), - text=alt.Text("score:Q", format=".4f"), - color=alt.Color("metric:N"), - ) - .transform_filter( - " || ".join([f"datum.alpha == {a}" for a in [0.0, 0.5, 1.0]]) - ) - ) - - return cast(alt.LayerChart, alt.layer(lines, points, text_labels)) - - # -- Left subplot -- - left_subplot = _create_subplot( - subplot_df=( - df_plot.filter( - pl.col("metric").is_in(left_subplot_metrics_to_colors.keys()) - ) - ), - y_title=lbl["y_left_title"], - colors=list(left_subplot_metrics_to_colors.values()), - domain=list(left_subplot_metrics_to_colors.keys()), - ) - - # -- Right subplot -- - right_subplot = _create_subplot( - subplot_df=( - df_plot.filter( - pl.col("metric").is_in(right_subplot_metrics_to_colors.keys()) - ) - ), - y_title=lbl["y_right_title"], - colors=list(right_subplot_metrics_to_colors.values()), - domain=list(right_subplot_metrics_to_colors.keys()), - ) - - # -- Final chart (horizontal concat) -- - chart = ( - (left_subplot | right_subplot) - .resolve_scale(color="independent") - .properties(title=lbl["title"] if not no_title else None) - ) - - for fmt in formats: - kwargs: dict[str, Any] = {"scale_factor": png_scale} if fmt == "png" else {} - path = out_dir / f"{out_filename}.{fmt}" - chart.save(path, **kwargs) - click.echo(f"Saved chart in {fmt.upper()} format to {path}") - - -@cli.command(name="sasrec-training") -@click.option( - "--log-file-path", - type=click.Path(exists=True, dir_okay=False, path_type=Path), - help="Path to the JSON log file to parse.", -) -@click.option( - "--log-level", - type=click.Choice(list(logging.getLevelNamesMapping()), case_sensitive=False), - default="INFO", - show_default=True, - help="Logging verbosity level.", -) -@click.option( - "--lang", - type=click.Choice(["pl", "en"], case_sensitive=False), - default="en", - show_default=True, - help="Language for axis labels and annotations.", -) -@click.option( - "--out-dir", - "-o", - type=click.Path(file_okay=False, path_type=Path), - default=None, - help=""" - Output directory for the generated plots and other artifacts. (e.g. ImpactConfig - YAML dumps). Defaults to /reports/plots/sasrec-training. - """, -) -@click.option( - "--format", - "-f", - "formats", - type=click.Choice(("svg", "pdf", "png"), case_sensitive=False), - multiple=True, - default=("svg", "pdf"), - show_default=True, - help="Output format(s). Can be specified multiple times: -f svg -f pdf.", -) -@click.option( - "--png-scale", - type=float, - default=3.0, - show_default=True, - help="Scale factor for PNG export. 3.0 is approx. 216 PPI - suitable for print.", -) -def plot_sasrec_training( - log_file_path: Path, - log_level: str = "INFO", - lang: str = "en", - out_dir: Path | None = None, - formats: tuple[Literal["svg", "pdf", "png"], ...] = ("svg", "pdf"), - png_scale: float = 3.0, -) -> None: - """Plot SASRec training curves from a structured NDJSON log file. - - Renders training loss (BCE) and validation NDCG@10 over epochs as a dual-axis chart, - styled for direct inclusion in a LaTeX thesis. - """ - configure_logging( - level=logging.getLevelNamesMapping()[log_level], - ) # Configure logging without piping logs to files - - out_dir = out_dir or PROJECT_ROOT / "reports" / "plots" / "sasrec-training" - out_dir.mkdir(parents=True, exist_ok=True) - - # -- Parse log file -- - logs_df = pl.read_ndjson( - source=log_file_path, - infer_schema_length=None, - schema_overrides={ - "epoch": pl.UInt32, - "train_loss": pl.Float64, - "val_ndcg": pl.Float64, - }, - ) - epochs_df = logs_df.filter(pl.col("epoch").is_not_null()).select( - pl.col("epoch"), pl.col("train_loss"), pl.col("val_ndcg") - ) - - # -- Labels per language -- - labels = { - "pl": { - "epoch": "Epoka", - "loss": "Strata treningowa (BCE)", - "ndcg": "NDCG@10 (walidacja)", - }, - "en": { - "epoch": "Epoch", - "loss": "Training Loss (BCE)", - "ndcg": "NDCG@10 (validation)", - }, - }[lang] - - # -- Shared x-axis -- - base = alt.Chart(epochs_df).encode( - x=alt.X( - shorthand="epoch:Q", - axis=alt.Axis(title=labels["epoch"], tickMinStep=1), - scale=alt.Scale(nice=False), - ) - ) - - color_loss = OKABE_ITO_PALETTE.blue - color_ndcg = OKABE_ITO_PALETTE.vermillion - - # -- Loss (left axis) -- - loss_line = base.mark_line(color=color_loss).encode( - y=alt.Y( - shorthand="train_loss:Q", - axis=alt.Axis( - title=labels["loss"], - titleColor=color_loss, - labelColor=color_loss, - tickColor=color_loss, - domainColor=color_loss, - gridOpacity=0.5, - ), - ), - ) - loss_points = base.mark_point(filled=True, color=color_loss).encode( - y=alt.Y(shorthand="train_loss:Q", axis=None) - ) - - # -- NDCG (right axis) -- - ndcg_line = base.mark_line(color=color_ndcg, strokeDash=[8, 4]).encode( - y=alt.Y( - shorthand="val_ndcg:Q", - axis=alt.Axis( - title=labels["ndcg"], - titleColor=color_ndcg, - labelColor=color_ndcg, - tickColor=color_ndcg, - domainColor=color_ndcg, - grid=False, - format=".4f", - ), - ), - ) - ndcg_points = base.mark_point(filled=True, color=color_ndcg).encode( - y=alt.Y("val_ndcg:Q", axis=None) - ) - - # -- Compose -- - chart = ( - alt.layer(loss_line, loss_points, ndcg_line, ndcg_points).resolve_scale( - y="independent" - ) - # .properties(width=380, height=240) - ) - - # -- Export -- - stem = f"sasrec_training_{log_file_path.stem}" - for fmt in formats: - kwargs: dict[str, Any] = {"scale_factor": png_scale} if fmt == "png" else {} - path = out_dir / f"{stem}.{fmt}" - chart.save(path, **kwargs) - logger.info(f"Saved chart in {fmt.upper()} format to {path}") - - -if __name__ == "__main__": - cli() diff --git a/scripts/plots_data.py b/scripts/plots_data.py new file mode 100644 index 0000000..12d99aa --- /dev/null +++ b/scripts/plots_data.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import dataclasses +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np +import pandera.polars as pa +import polars as pl +import structlog +from pandera.typing.polars import Series # noqa: TC002 + +from impact.pipelines import DataPipeline, ImpactEvaluationPipeline +from impact.utils import seed_everything + +if TYPE_CHECKING: + from pathlib import Path + + from pandera.typing.polars import DataFrame + + from impact.config import ImpactConfig + +logger = structlog.stdlib.get_logger(__name__) + + +class AlphaCurvePlotSchema(pa.DataFrameModel): + """Schema for the alpha curve plot data.""" + + alpha: Series[pl.Float64] = pa.Field() + metric: Series[pl.String] = pa.Field() + score: Series[pl.Float64] = pa.Field() + + +def compute_alpha_curve_plot_data( + impact_config: ImpactConfig, + sasrec_checkpoint_path: Path, + raw_data_dir: Path, + interim_data_dir: Path, + processed_data_dir: Path, + alpha_start: float, + alpha_stop: float, + alpha_step: float, + metrics_at_k: int, +) -> DataFrame[AlphaCurvePlotSchema]: + """Compute data for the alpha curve plot.""" + seed_everything(impact_config.system.seed) + + data_pipeline = DataPipeline( + raw_dir=raw_data_dir, + interim_dir=interim_data_dir, + processed_dir=processed_data_dir, + config=impact_config, + ) + + logger.info("Fetching listening events data...") + listens_lf = data_pipeline.get_listen_events() + + logger.info("Fetching train/val/test splits...") + splits, item_index = data_pipeline.get_splits() + + logger.info("Tensorizing splits...") + tensorized_splits = data_pipeline.tensorize(splits) + + alphas = np.round( + a=np.arange(alpha_start, alpha_stop + alpha_step / 2, alpha_step), + decimals=2, + ) + results: list[dict[str, float]] = [] + + for alpha in alphas: + eval_config = dataclasses.replace( + impact_config, + eval=dataclasses.replace( + impact_config.eval, + alpha=float(alpha), + k_cutoffs=(metrics_at_k,), + ), + ) + + logger.info(f"Running IMPACT evaluation for {alpha=}...") + eval_pipeline = ImpactEvaluationPipeline(eval_config, item_index, listens_lf) + eval_result = eval_pipeline.evaluate( + split_partition=tensorized_splits.val, + sasrec_state_dict_path=sasrec_checkpoint_path, + ) + + row = {"alpha": float(alpha), **eval_result.metrics} + results.append(row) + + df = ( + pl.DataFrame(results) + .unpivot( + on=[r for r in results[0] if r != "alpha"], + index="alpha", + variable_name="metric", + value_name="score", + ) + .filter(pl.col("metric").str.contains("(?i)ndcg")) # only NDCG + ) + return AlphaCurvePlotSchema.validate(df) diff --git a/scripts/plots_render.py b/scripts/plots_render.py new file mode 100644 index 0000000..3435cba --- /dev/null +++ b/scripts/plots_render.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import TYPE_CHECKING, Any, cast + +import altair as alt +import polars as pl +import structlog + +from .plots_data import AlphaCurvePlotSchema + +if TYPE_CHECKING: + from pathlib import Path + + from pandera.typing.polars import DataFrame + + from .types import Language, PlotFormat + + +logger = structlog.stdlib.get_logger(__name__) + + +class OkabeItoPalette(StrEnum): + """Okabe-Ito palette which is colorblind-safe and prints well in grayscale.""" + + ORANGE = "#E69F00" + SKY_BLUE = "#56B4E9" + BLUISH_GREEN = "#009E73" + YELLOW = "#F0E442" + BLUE = "#0072B2" + VERMILLION = "#D55E00" + REDDISH_PURPLE = "#CC79A7" + BLACK = "#000000" + + +# Chart theme styling +FONT: str = "Libertinus Serif" # Match the serif font used in the thesis +FONT_SIZE_BASE = 12 # Match the 12pt font used in the thesis +FONT_SIZE_SMALL = 10 +FONT_SIZE_TINY = 8 +COLOR_INK = "#1A1A1A" # Labels, ticks, axis domains +COLOR_GRID = "#E0E0E0" # Passive guides - gridlines, legend borders, etc. + +# PNG rendering settings +PNG_SCALE = 3.0 + + +@alt.theme.register("thesis_theme", enable=True) +def thesis_theme() -> alt.theme.ThemeConfig: + """Register and enable an Altair theme for publication-quality visualizations.""" + return { + "config": { + # -- Canvas -- + "view": { + "strokeWidth": 0, # No border around plot area + "continuousWidth": 400, + "continuousHeight": 250, # Keep the aspect ratio of 16:10 (approx.) + }, + "background": "white", + # -- Typography -- + "title": { + "font": FONT, + "fontSize": FONT_SIZE_BASE, + "fontWeight": "bold", + "color": COLOR_INK, + "anchor": "middle", + }, + # -- Axes -- + "axis": { + "labelFont": FONT, + "titleFont": FONT, + "labelFontSize": FONT_SIZE_SMALL, + "titleFontSize": FONT_SIZE_BASE, + "titleFontWeight": "normal", # Let the thesis captions catch the eye + "labelColor": COLOR_INK, + "titleColor": COLOR_INK, + "domainColor": COLOR_INK, + "domainWidth": 1, + "tickColor": COLOR_INK, + "tickWidth": 1, + "gridColor": COLOR_GRID, + "gridWidth": 0.5, + "gridDash": [4, 4], + }, + "axisX": {"grid": False}, # Disable vertical gridlines + "axisY": {"grid": True}, # Keep the horizontal though + # -- Legend -- + "legend": { + "labelFont": FONT, + "titleFont": FONT, + "labelFontSize": FONT_SIZE_SMALL, + "titleFontSize": FONT_SIZE_SMALL, + "titleFontWeight": "normal", # Let the thesis captions catch the eye + "orient": "top-right", + "fillColor": "white", + "strokeColor": COLOR_GRID, + "padding": 5, + "cornerRadius": 2, + }, + # -- Header -- + "header": { + "labelFont": FONT, + "titleFont": FONT, + }, + # -- Marks -- + "line": {"strokeWidth": 1.6}, + "point": {"size": 35, "filled": True}, + "bar": {"fill": OkabeItoPalette.BLUE.value}, + # -- Color scale default -- + "range": { + "category": [e.value for e in OkabeItoPalette], + "ordinal": [e.value for e in OkabeItoPalette], + }, + } + } + + +def render_alpha_curve_plot( + alpha_curve_df: DataFrame[AlphaCurvePlotSchema], + alpha_start: float, + alpha_stop: float, + out_dir: Path, + out_filename: str, + formats: tuple[PlotFormat, ...], + lang: Language = "en", + no_title: bool = False, +) -> tuple[Path, ...]: + """Plot IMPACT evaluation metrics as a function of alpha. + + Alpha controls the relative contribution of SASRec and ACT-R in the final + late-fusion score (trade-off). + """ + # -- Labels per language -- + labels: dict[Language, dict[str, str]] = { + "en": { + "x_title": "α (0 = pure ACT-R, 1 = pure SASRec)", # noqa: RUF001 + "y_left_title": "NDCG@10 (Overall & Repetition)", + "y_right_title": "NDCG@10 (Exploration)", + "title": "Effect of α on IMPACT Metrics", # noqa: RUF001 + "ndcg_at_10": "NDCG@10 (Overall)", + "ndcg_local_rep_at_10": "Local Repetition", + "ndcg_global_rep_at_10": "Global Repetition", + "ndcg_local_exp_at_10": "Local Exploration", + "ndcg_global_exp_at_10": "Global Exploration", + }, + "pl": { + "x_title": "α (0 = wyłącznie ACT-R, 1 = wyłącznie SASRec)", # noqa: RUF001 + "y_left_title": "NDCG@10 (ogólne i powtarzalność)", + "y_right_title": "NDCG@10 (eksploracja)", + "title": "Wpływ parametru α na metryki IMPACT", # noqa: RUF001 + "ndcg_at_10": "NDCG@10 (ogólne)", + "ndcg_local_rep_at_10": "Powtarzalność lokalna", + "ndcg_global_rep_at_10": "Powtarzalność globalna", + "ndcg_local_exp_at_10": "Eksploracja lokalna", + "ndcg_global_exp_at_10": "Eksploracja globalna", + }, + } + lbl = labels[lang] + + df_plot = alpha_curve_df.with_columns( + pl.col(AlphaCurvePlotSchema.metric).replace(labels[lang]) + ) + + # Subplots specs + left_subplot_metrics_to_colors: dict[str, str] = { + lbl[metric]: color + for metric, color in { + "ndcg_at_10": OkabeItoPalette.BLACK.value, + "ndcg_local_rep_at_10": OkabeItoPalette.BLUE.value, + "ndcg_global_rep_at_10": OkabeItoPalette.VERMILLION.value, + }.items() + } + right_subplot_metrics_to_colors: dict[str, str] = { + lbl[metric]: color + for metric, color in { + "ndcg_local_exp_at_10": OkabeItoPalette.ORANGE.value, + "ndcg_global_exp_at_10": OkabeItoPalette.BLUISH_GREEN.value, + }.items() + } + + # Shared x-axis + base_x = alt.X( + "alpha:Q", + title=lbl["x_title"], + scale=alt.Scale(domain=[alpha_start, alpha_stop]), + axis=alt.Axis(tickCount=5, grid=False), + ) + + def _create_subplot( + subplot_df: pl.DataFrame, + y_title: str, + colors: list[str], + domain: list[str], + ) -> alt.LayerChart: + base = alt.Chart(subplot_df) + + lines = base.mark_line().encode( + x=base_x, + y=alt.Y("score:Q", title=y_title, scale=alt.Scale(zero=False)), + color=alt.Color( + "metric:N", + scale=alt.Scale(domain=domain, range=colors), + legend=alt.Legend(title=None), + ), + tooltip=["alpha:Q", "metric:N", alt.Tooltip("score:Q", format=".4f")], + ) + + points = base.mark_point(filled=True).encode( + x=base_x, + y=alt.Y("score:Q"), + color=alt.Color("metric:N"), + ) + + text_labels = ( + base.mark_text( + align="left", + baseline="middle", + dx=6, # offset to the right + dy=-6, # offset up + fontSize=FONT_SIZE_TINY, + opacity=0.6, + ) + .encode( + x=base_x, + y=alt.Y("score:Q"), + text=alt.Text("score:Q", format=".4f"), + color=alt.Color("metric:N"), + ) + .transform_filter( + " || ".join([f"datum.alpha == {a}" for a in [0.0, 0.5, 1.0]]) + ) + ) + + return cast(alt.LayerChart, alt.layer(lines, points, text_labels)) + + # Left subplot + left_subplot = _create_subplot( + subplot_df=( + df_plot.filter( + pl.col("metric").is_in(left_subplot_metrics_to_colors.keys()) + ) + ), + y_title=lbl["y_left_title"], + colors=list(left_subplot_metrics_to_colors.values()), + domain=list(left_subplot_metrics_to_colors.keys()), + ) + + # Right subplot + right_subplot = _create_subplot( + subplot_df=( + df_plot.filter( + pl.col("metric").is_in(right_subplot_metrics_to_colors.keys()) + ) + ), + y_title=lbl["y_right_title"], + colors=list(right_subplot_metrics_to_colors.values()), + domain=list(right_subplot_metrics_to_colors.keys()), + ) + + # Final chart + chart = (left_subplot | right_subplot).resolve_scale(color="independent") + if not no_title: + chart = chart.properties(title=lbl["title"]) + + out_files: list[Path] = [] + for fmt in formats: + kwargs: dict[str, Any] = {"scale_factor": PNG_SCALE} if fmt == "png" else {} + out_file = out_dir / f"{out_filename}.{fmt}" + chart.save(out_file, **kwargs) + out_files.append(out_file) + + return tuple(out_files) diff --git a/scripts/tables_render_latex.py b/scripts/tables_render_latex.py index 71d5c76..a597800 100644 --- a/scripts/tables_render_latex.py +++ b/scripts/tables_render_latex.py @@ -8,13 +8,15 @@ from .tables_data import DataFunnelStatsSchema if TYPE_CHECKING: + from pandera.typing.polars import DataFrame + from .types import Language logger = structlog.stdlib.get_logger(__name__) def render_data_funnel_stats( - data_funnel_stats_df: pl.DataFrame, + data_funnel_stats_df: DataFrame[DataFunnelStatsSchema], lang: Language = "en", ) -> str: """Generate a LaTeX table of record counts across data preparation stages.""" diff --git a/scripts/tables_render_markdown.py b/scripts/tables_render_markdown.py index 5d7ab56..0d3d5c9 100644 --- a/scripts/tables_render_markdown.py +++ b/scripts/tables_render_markdown.py @@ -8,13 +8,15 @@ from .tables_data import DataFunnelStatsSchema if TYPE_CHECKING: + from pandera.typing.polars import DataFrame + from .types import Language logger = structlog.stdlib.get_logger(__name__) def render_data_funnel_stats( - data_funnel_stats_df: pl.DataFrame, + data_funnel_stats_df: DataFrame[DataFunnelStatsSchema], lang: Language = "en", ) -> str: """Generate a Markdown table of record counts across data preparation stages.""" diff --git a/scripts/types.py b/scripts/types.py index 98f149e..d45da9c 100644 --- a/scripts/types.py +++ b/scripts/types.py @@ -4,3 +4,4 @@ type Language = Literal["en", "pl"] type TableFormat = Literal["latex", "markdown"] +type PlotFormat = Literal["svg", "pdf", "png"]