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/pyproject.toml b/pyproject.toml index f92e900..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,10 +13,12 @@ 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", "pyyaml>=6.0.3", + "scipy>=1.17.1", "structlog>=25.5.0", "tqdm>=4.67.3", ] @@ -28,6 +30,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", @@ -247,13 +250,13 @@ 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 }, { 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/tests/__init__.py b/scripts/__init__.py similarity index 100% rename from tests/__init__.py rename to scripts/__init__.py diff --git a/scripts/cli.py b/scripts/cli.py new file mode 100644 index 0000000..14ae146 --- /dev/null +++ b/scripts/cli.py @@ -0,0 +1,308 @@ +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, + inject_impact_config_from_checkpoint, + inject_impact_config_overrides, +) +from impact.logger import configure_logging + +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, PlotFormat, TableFormat + +PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[1] + + +@click.group() +def cli() -> None: + """CLI entry point for the scripts.""" + pass + + +@cli.group(name="tables") +def tables() -> None: + """Commands for generating tables (used in thesis/documentation).""" + 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(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( + "--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 rendered table 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 = "WARNING", + 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]) + + 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", + ) + + match fmt: + case "latex": + table = tables_render_latex.render_data_funnel_stats(data_df, lang) + ext = "tex" + 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" + 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-{lang}.{ext}" + _ = out_file.write_text(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.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..03723a7 --- /dev/null +++ b/scripts/tables_data.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import dataclasses +from typing import TYPE_CHECKING, Final, 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() + + +@dataclasses.dataclass(frozen=True) +class DataFunnelVariant: + """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..a597800 --- /dev/null +++ b/scripts/tables_render_latex.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import polars as pl +import structlog + +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: DataFrame[DataFunnelStatsSchema], + 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 + }, + "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 + }, + } + 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}, + } + ) + + return ( + df.to_pandas() + .style.hide(axis="index") + # Generate LaTeX table using Pandas Styler + .to_latex( + hrules=True, + 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 + ) + ) + + +def _format_num(n: int) -> str: + """Format integer with space as thousands separator.""" + return f"{n:,}".replace(",", "\\,") diff --git a/scripts/tables_render_markdown.py b/scripts/tables_render_markdown.py new file mode 100644 index 0000000..0d3d5c9 --- /dev/null +++ b/scripts/tables_render_markdown.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import polars as pl +import structlog + +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: DataFrame[DataFunnelStatsSchema], + 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(",", " ") diff --git a/scripts/types.py b/scripts/types.py new file mode 100644 index 0000000..d45da9c --- /dev/null +++ b/scripts/types.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from typing import Literal + +type Language = Literal["en", "pl"] +type TableFormat = Literal["latex", "markdown"] +type PlotFormat = Literal["svg", "pdf", "png"] diff --git a/src/impact/evaluation/__init__.py b/src/impact/evaluation/__init__.py index 60ea3b0..ec64d16 100644 --- a/src/impact/evaluation/__init__.py +++ b/src/impact/evaluation/__init__.py @@ -1,6 +1,19 @@ -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, +) +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", "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() 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/src/impact/main.py b/src/impact/main.py index 74b1f6d..871ff93 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()) @@ -192,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), @@ -232,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, @@ -247,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", @@ -297,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") @@ -421,7 +463,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 +496,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 +508,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 +529,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/impact_evaluation_pipeline.py b/src/impact/pipelines/impact_evaluation_pipeline.py index 68702d8..6d1c265 100644 --- a/src/impact/pipelines/impact_evaluation_pipeline.py +++ b/src/impact/pipelines/impact_evaluation_pipeline.py @@ -5,12 +5,16 @@ 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 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, @@ -24,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 @@ -62,6 +64,44 @@ 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] + + +@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. @@ -69,7 +109,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. @@ -78,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__( @@ -106,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}` @@ -128,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") @@ -161,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, @@ -175,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 @@ -212,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): ( @@ -233,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.""" @@ -329,13 +402,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 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. - 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. + 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 +421,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, 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") diff --git a/uv.lock b/uv.lock index 6bcd4b5..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,18 +605,20 @@ dependencies = [ { name = "huggingface-hub" }, { name = "jinja2" }, { name = "pandas" }, + { name = "pandera", extra = ["polars"] }, { name = "polars", extra = ["rt64"] }, { 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" } }, @@ -623,6 +634,7 @@ dev = [ { name = "mypy" }, { name = "pandas-stubs" }, { name = "ruff" }, + { name = "scipy-stubs" }, { name = "types-psutil" }, { name = "types-pyyaml" }, { name = "types-tqdm" }, @@ -635,10 +647,12 @@ 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" }, { 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" } }, @@ -654,6 +668,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" }, @@ -1119,6 +1134,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" @@ -1444,6 +1471,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" @@ -1517,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" @@ -1715,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" @@ -1996,6 +2152,69 @@ 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.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +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]] name = "setuptools" version = "81.0.0" @@ -2112,18 +2331,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 +2362,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" }, @@ -2313,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" @@ -2379,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"