mocaco 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mocaco/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Main module to expose the mocaco API."""
2
+
3
+ from .api import convergence, describe, methods, samples
4
+
5
+ __all__ = [
6
+ "convergence",
7
+ "describe",
8
+ "methods",
9
+ "samples",
10
+ ]
mocaco/api.py ADDED
@@ -0,0 +1,109 @@
1
+ """Main module to define the API behaviour and usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from . import methods as _methods # noqa: F401
8
+ from .protocols import SampleFrame
9
+ from .registry import registry
10
+ from .wrappers import EvalFrequencyWrapper
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
14
+
15
+ import polars as pl
16
+
17
+ from .result import ConvergenceResult
18
+
19
+
20
+ def samples(
21
+ df: pl.DataFrame,
22
+ *,
23
+ target_col: str,
24
+ it_col: str | None = None,
25
+ ) -> SampleFrame:
26
+ """
27
+ Create the normalized and frozen data representation used by convergence criteria.
28
+
29
+ This prevents input errors and ensure the input data is read-only and is
30
+ never modified accidentally.
31
+
32
+ Parameters
33
+ ----------
34
+ df
35
+ Source Polars DataFrame.
36
+ it_col
37
+ Column containing the iteration/order of the samples. If the user omit it, it
38
+ is automatically completed.
39
+ target_col
40
+ Column containing the quantity to analyze.
41
+ """
42
+ return SampleFrame(
43
+ df=df,
44
+ target_col=target_col,
45
+ it_col=it_col,
46
+ )
47
+
48
+
49
+ class Convergence:
50
+ """
51
+ Callable namespace exposing registered convergence criteria as attributes.
52
+
53
+ Use as ``convergence(samples, method="...", **kwargs)`` for generic calls,
54
+ or ``convergence.clt_absolute(samples, threshold=...)`` for typed calls.
55
+ """
56
+
57
+ def __call__(
58
+ self,
59
+ samples: SampleFrame,
60
+ *,
61
+ method: str,
62
+ **kwargs: Any,
63
+ ) -> ConvergenceResult:
64
+ """Invoke a registered convergence criterion generically by name."""
65
+ criterion = registry.get(method)
66
+ eval_frequency = kwargs.pop("eval_frequency", None)
67
+ params = criterion.params_type(**kwargs)
68
+
69
+ if eval_frequency is not None:
70
+ if not getattr(criterion, "supports_eval_frequency", False):
71
+ raise TypeError(f"Criterion '{criterion.name}' does not support eval_frequency")
72
+ wrapper = EvalFrequencyWrapper(criterion, eval_frequency=eval_frequency)
73
+ return wrapper.run(samples=samples, params=params)
74
+
75
+ return criterion.run(
76
+ samples=samples,
77
+ params=params,
78
+ )
79
+
80
+ def __getattr__(self, name: str) -> Callable[..., ConvergenceResult]:
81
+ """Resolve registered criteria as callable attributes."""
82
+ criterion = registry.get(name)
83
+
84
+ def _method(samples: SampleFrame, **kwargs: Any) -> ConvergenceResult:
85
+ eval_frequency = kwargs.pop("eval_frequency", None)
86
+ params = criterion.params_type(**kwargs)
87
+
88
+ if eval_frequency is not None:
89
+ if not getattr(criterion, "supports_eval_frequency", False):
90
+ raise TypeError(f"Criterion '{criterion.name}' does not support eval_frequency")
91
+ wrapper = EvalFrequencyWrapper(criterion, eval_frequency=eval_frequency)
92
+ return wrapper.run(samples=samples, params=params)
93
+
94
+ return criterion.run(samples=samples, params=params)
95
+
96
+ return _method
97
+
98
+
99
+ convergence = Convergence()
100
+
101
+
102
+ def methods() -> tuple[str, ...]:
103
+ """Return the names of all registered convergence criteria."""
104
+ return registry.names()
105
+
106
+
107
+ def describe(name: str) -> None:
108
+ """Return metadata and parameter information for a criterion."""
109
+ return registry.describe(name)
mocaco/api.pyi ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import polars as pl
6
+
7
+ from .protocols import SampleFrame
8
+ from .result import ConvergenceResult
9
+
10
+ def samples(df: pl.DataFrame, *, target_col: str, it_col: str | None = None) -> SampleFrame: ...
11
+ def methods() -> tuple[str, ...]: ...
12
+ def describe(name: str) -> None: ...
13
+
14
+ class Convergence:
15
+ def __call__(
16
+ self, samples: SampleFrame, *, method: str, **kwargs: Any
17
+ ) -> ConvergenceResult: ...
18
+ def clt_absolute(
19
+ self,
20
+ samples: SampleFrame,
21
+ *,
22
+ threshold: float,
23
+ confidence_level: float = 0.95,
24
+ eval_frequency: int = 1,
25
+ ) -> ConvergenceResult:
26
+ """
27
+ Central Limit Theorem based convergence criterion using an absolute Monte Carlo error threshold.
28
+
29
+ Parameters
30
+ ----------
31
+ threshold : float
32
+ Maximum accepted absolute Monte Carlo error.
33
+ confidence_level : float, default 0.95
34
+ Confidence level used for the CLT margin of error.
35
+ eval_frequency : int, default 1
36
+ Number of rows between successive evaluations.
37
+ """
38
+ ...
39
+
40
+ convergence: Convergence
@@ -0,0 +1,7 @@
1
+ """Module to automatically expose all addded methods."""
2
+
3
+ import importlib
4
+ import pkgutil
5
+
6
+ for _finder, module_name, _ispkg in pkgutil.iter_modules(__path__):
7
+ importlib.import_module(f"{__name__}.{module_name}")
@@ -0,0 +1,133 @@
1
+ """Central Limit Theorem based convergence criterion using an absolute Monte Carlo error threshold."""
2
+
3
+ # Mandatory basic modules
4
+ from __future__ import annotations
5
+
6
+ from typing import TYPE_CHECKING
7
+
8
+ # Basic modules used to build the criterion (According contributor needs)
9
+ import polars as pl
10
+ from pydantic import BaseModel, Field
11
+ from scipy.stats import norm
12
+
13
+ # Modules needed from mocaco
14
+ from mocaco.registry import registry
15
+ from mocaco.result import ConvergenceResult
16
+
17
+ if TYPE_CHECKING:
18
+ from mocaco.protocols import SampleFrame
19
+
20
+
21
+ # 1. Criterion input parameters
22
+ class CLTAbsoluteParams(BaseModel):
23
+ threshold: float = Field(
24
+ ...,
25
+ gt=0,
26
+ description="Maximum accepted absolute Monte Carlo error.",
27
+ )
28
+
29
+ confidence_level: float = Field(
30
+ 0.95,
31
+ gt=0,
32
+ lt=1,
33
+ description="Confidence level used for the CLT margin of error.",
34
+ )
35
+
36
+
37
+ # 2. Criterion logic (calculous)
38
+ @registry.register()
39
+ class CLTAbsoluteCriterion:
40
+ name = "clt_absolute"
41
+
42
+ supports_eval_frequency = True
43
+
44
+ description = (
45
+ "Central Limit Theorem based convergence criterion "
46
+ "using an absolute Monte Carlo error threshold."
47
+ )
48
+
49
+ params_type = CLTAbsoluteParams
50
+
51
+ assumptions = (
52
+ "Samples are independent and identically distributed (i.i.d.).",
53
+ "The target quantity has finite variance.",
54
+ "The sample size is sufficiently large for the CLT to provide a normal approximation. The deafult stb_window = 30 aims to support this assumption.",
55
+ )
56
+
57
+ limitations = (
58
+ "This is a probabilistic stopping criterion, not a proof of convergence.",
59
+ "For small sample sizes, the normal approximation may be inaccurate.",
60
+ "Does not detect systematic bias or non-stationarity in the sampling process.",
61
+ "The stability window introduces a lag in detecting convergence.",
62
+ )
63
+
64
+ result_interpretation = (
65
+ "is_converged is True when the CLT-based absolute error has stayed below "
66
+ "the threshold for at least stb_window consecutive iterations. "
67
+ "estimate is the cumulative mean at the final sample. "
68
+ "error is the CLT-based absolute error (z_score * SEM) at the final sample. "
69
+ "n is the total number of samples evaluated."
70
+ )
71
+
72
+ example_usage = """\
73
+ import polars as pl
74
+ import mocaco as mcc
75
+
76
+ df = pl.DataFrame({"it": range(100), "value": [1.0] * 100})
77
+ samples = mcc.samples(df, it_col="it", target_col="value")
78
+ result = mcc.convergence.clt_absolute(samples, threshold=0.1)
79
+ print(result.is_converged)"""
80
+
81
+ references = ("https://en.wikipedia.org/wiki/Central_limit_theorem",)
82
+
83
+ def run(
84
+ self,
85
+ samples: SampleFrame,
86
+ params: CLTAbsoluteParams,
87
+ ) -> ConvergenceResult:
88
+
89
+ target = pl.col(samples.target_col).cast(pl.Float64)
90
+
91
+ agg_df = samples.df.select(
92
+ [pl.len().alias("n"), target.mean().alias("mean"), target.std().alias("std")]
93
+ )
94
+
95
+ final = agg_df.row(0, named=True)
96
+ n = final["n"]
97
+ mean = final["mean"]
98
+ std = final["std"] if final["std"] is not None else 0.0
99
+
100
+ sem = std / (n**0.5) if n > 0 else 0.0
101
+ z_score = float(norm.ppf(1.0 - (1.0 - params.confidence_level) / 2.0))
102
+ abs_error = sem * z_score
103
+
104
+ is_converged = abs_error <= params.threshold
105
+
106
+ final_df = pl.DataFrame(
107
+ {
108
+ "n": [n],
109
+ "mean": [mean],
110
+ "std": [std],
111
+ "sem": [sem],
112
+ "abs_error": [abs_error],
113
+ "is_converged": [is_converged],
114
+ }
115
+ )
116
+
117
+ return ConvergenceResult(
118
+ method=self.name,
119
+ n=int(n),
120
+ n_col="n",
121
+ estimate=float(mean) if mean is not None else None,
122
+ estimate_col="mean",
123
+ error=float(abs_error),
124
+ error_col="abs_error",
125
+ is_converged=is_converged,
126
+ is_converged_col="is_converged",
127
+ data=final_df,
128
+ diagnostics={
129
+ "threshold": params.threshold,
130
+ "confidence_level": params.confidence_level,
131
+ "z_score": z_score,
132
+ },
133
+ )
mocaco/protocols.py ADDED
@@ -0,0 +1,104 @@
1
+ """Protocol for method addition and its input data format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Any, ClassVar, Protocol
8
+
9
+ import polars as pl
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Sequence
13
+
14
+ from .result import ConvergenceResult
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SampleFrame:
21
+ """
22
+ Normalized representation of Monte Carlo samples.
23
+
24
+ Parameters
25
+ ----------
26
+ df
27
+ Source Polars DataFrame.
28
+ it_col
29
+ Column containing the iteration/order of the samples.
30
+ target_col
31
+ Column containing the quantity to analyze.
32
+ """
33
+
34
+ df: pl.DataFrame
35
+ target_col: str
36
+ it_col: str | None = None
37
+
38
+ def __post_init__(self) -> None:
39
+ """Validate columns, assign default iteration indices if missing, and handle edge cases."""
40
+ # 1. Resolve and normalize it_col name
41
+ if self.it_col is None:
42
+ it_col_name = "it"
43
+ new_df = self.df.with_columns(pl.arange(1, len(self.df) + 1).alias(it_col_name))
44
+ object.__setattr__(self, "df", new_df)
45
+ object.__setattr__(self, "it_col", it_col_name)
46
+ logger.info("it_col was None — auto-created column 'it' with sequential indices.")
47
+ else:
48
+ it_col_name = self.it_col
49
+
50
+ # 2. Validate structural integrity (it_col_name is guaranteed to be str here)
51
+ missing = [col for col in (it_col_name, self.target_col) if col not in self.df.columns]
52
+ if missing:
53
+ raise ValueError(
54
+ f"Columns not found in DataFrame: {missing}. Available columns: {self.df.columns}"
55
+ )
56
+
57
+ if self.df.is_empty():
58
+ raise ValueError("The sample DataFrame cannot be empty.")
59
+
60
+ # 3. Validate Data Types (it_col and target_col must be numeric)
61
+ it_dtype = self.df.get_column(it_col_name).dtype
62
+ if not it_dtype.is_numeric():
63
+ raise TypeError(
64
+ f"Iteration column '{it_col_name}' must be numeric (int or float), got {it_dtype}."
65
+ )
66
+
67
+ target = self.df.get_column(self.target_col)
68
+ if not target.dtype.is_numeric():
69
+ raise TypeError(
70
+ f"Target column '{self.target_col}' must be numeric (int or float), got {target.dtype}."
71
+ )
72
+
73
+ # 4. Input validation and edge case handling (Null, NaN, Inf)
74
+ if target.is_null().any():
75
+ raise ValueError(f"Target column '{self.target_col}' contains Null (missing) values.")
76
+
77
+ if target.dtype in (pl.Float32, pl.Float64):
78
+ if target.is_nan().any():
79
+ raise ValueError(f"Target column '{self.target_col}' contains NaN values.")
80
+ if target.is_infinite().any():
81
+ raise ValueError(
82
+ f"Target column '{self.target_col}' contains Infinite (Inf) values."
83
+ )
84
+
85
+
86
+ class Criterion(Protocol):
87
+ """Structural contract implemented by every convergence criterion."""
88
+
89
+ name: str
90
+ supports_eval_frequency: bool
91
+ description: str
92
+ params_type: type[Any]
93
+
94
+ assumptions: ClassVar[Sequence[str]]
95
+ limitations: ClassVar[Sequence[str]]
96
+ result_interpretation: str
97
+ example_usage: str
98
+ references: ClassVar[Sequence[str]]
99
+
100
+ def run(
101
+ self,
102
+ samples: SampleFrame,
103
+ params: Any,
104
+ ) -> ConvergenceResult: ...
mocaco/registry.py ADDED
@@ -0,0 +1,126 @@
1
+ """Registry singleton of mocaco convergence methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, TypeVar, overload
6
+
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+
14
+ from .protocols import Criterion
15
+
16
+ C = TypeVar("C", bound=type)
17
+
18
+ console = Console()
19
+
20
+
21
+ class CriterionRegistry:
22
+ def __init__(self) -> None:
23
+ self._criteria: dict[str, Criterion] = {}
24
+
25
+ # Overload 1: Decorator usage (no criterion provided)
26
+ @overload
27
+ def register(self, criterion: None = None, *, overwrite: bool = False) -> Callable[[C], C]: ...
28
+
29
+ # Overload 2: Programmatic usage (criterion instance provided)
30
+ @overload
31
+ def register(self, criterion: Criterion, *, overwrite: bool = False) -> Criterion: ...
32
+
33
+ def register(
34
+ self,
35
+ criterion: Criterion | None = None,
36
+ *,
37
+ overwrite: bool = False,
38
+ ) -> Any:
39
+ # 1. Programmatic use: registry.register(MyCriterion())
40
+ if criterion is not None:
41
+ name = criterion.name
42
+
43
+ if not name:
44
+ raise ValueError("A criterion must define a non-empty name.")
45
+
46
+ if name in self._criteria and not overwrite:
47
+ raise ValueError(f"Criterion {name!r} is already registered.")
48
+
49
+ self._criteria[name] = criterion
50
+ return criterion
51
+
52
+ # 2. Decorator use: @registry.register()
53
+ def wrapper(cls: C) -> C:
54
+ instance = cls()
55
+ self.register(instance, overwrite=overwrite)
56
+ return cls
57
+
58
+ return wrapper
59
+
60
+ def get(self, name: str) -> Criterion:
61
+ try:
62
+ return self._criteria[name]
63
+ except KeyError as exc:
64
+ available = ", ".join(self.names())
65
+ raise ValueError(
66
+ f"Unknown convergence criterion: {name!r}. Available criteria: {available}"
67
+ ) from exc
68
+
69
+ def names(self) -> tuple[str, ...]:
70
+ return tuple(self._criteria.keys())
71
+
72
+ def describe(self, name: str) -> None:
73
+ # 1. Fetch the criterion from the registry
74
+ try:
75
+ criterion = registry.get(name)
76
+ except ValueError:
77
+ console.print(f"[bold red]Error:[/bold red] Method '{name}' not found in registry.")
78
+ return
79
+
80
+ # 2. Print the Method Name and Main Description in a Panel
81
+ console.print()
82
+ console.print(
83
+ Panel(
84
+ f"{criterion.description}",
85
+ title=f"Method: [bold cyan]{name}[/bold cyan]",
86
+ title_align="left",
87
+ border_style="cyan",
88
+ expand=False,
89
+ )
90
+ )
91
+
92
+ # 3. Create a Table for the Parameters
93
+ table = Table(
94
+ title="Parameters",
95
+ title_style="bold magenta",
96
+ title_justify="left",
97
+ show_header=True,
98
+ header_style="bold black on white",
99
+ )
100
+
101
+ table.add_column("Name", style="bold cyan", no_wrap=True)
102
+ table.add_column("Type", style="green")
103
+ table.add_column("Required", justify="center")
104
+ table.add_column("Default", style="yellow")
105
+ table.add_column("Description", style="dim")
106
+
107
+ # 4. Introspect the Pydantic V2 model fields
108
+ params_type = criterion.params_type
109
+
110
+ for field_name, field_info in params_type.model_fields.items():
111
+ # Get type name cleanly
112
+ field_type = getattr(field_info.annotation, "__name__", str(field_info.annotation))
113
+
114
+ is_required = field_info.is_required()
115
+ req_icon = "[green]✔[/green]" if is_required else "[red]✘[/red]"
116
+ default_val = "-" if is_required else repr(field_info.default)
117
+ desc = field_info.description or "No description provided."
118
+
119
+ table.add_row(field_name, field_type, req_icon, default_val, desc)
120
+
121
+ # 5. Render the table
122
+ console.print(table)
123
+ console.print()
124
+
125
+
126
+ registry = CriterionRegistry()
mocaco/result.py ADDED
@@ -0,0 +1,222 @@
1
+ """Module to format Result output from convergence methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import matplotlib.pyplot as plt
9
+ import polars as pl
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+
14
+ if TYPE_CHECKING:
15
+ import pandas as pd
16
+
17
+
18
+ class ResultPlotter:
19
+ """Helper class to provide plotting capabilities for ConvergenceResult."""
20
+
21
+ def __init__(self, result: ConvergenceResult) -> None:
22
+ self.result = result
23
+
24
+ def evolution(
25
+ self,
26
+ *,
27
+ show: bool = False,
28
+ ) -> Any:
29
+ """
30
+ Plot the evolution of the estimate, the error band, and the convergence point.
31
+
32
+ Requires `matplotlib` to be installed.
33
+
34
+ Parameters
35
+ ----------
36
+ show
37
+ If True, automatically displays the plot with `plt.show()` and cleans up resources.
38
+
39
+ Returns
40
+ -------
41
+ matplotlib.axes.Axes
42
+ The matplotlib Axes object containing the plot.
43
+ """
44
+ df = self.result.data
45
+
46
+ # Ensure required columns exist
47
+ missing = [
48
+ col
49
+ for col in (
50
+ self.result.estimate_col,
51
+ self.result.error_col,
52
+ self.result.n_col,
53
+ self.result.is_converged_col,
54
+ )
55
+ if col not in self.result.data.columns
56
+ ]
57
+ if missing:
58
+ raise ValueError(f"Columns not found in diagnostic data for plotting: {missing}")
59
+
60
+ # Extract arrays
61
+ n_vals = df.get_column(self.result.n_col).to_numpy()
62
+ estimate_vals = df.get_column(self.result.estimate_col).to_numpy()
63
+ error_vals = df.get_column(self.result.error_col).to_numpy()
64
+
65
+ fig, ax = plt.subplots(figsize=(8, 5))
66
+
67
+ # Plot estimate line
68
+ ax.plot(n_vals, estimate_vals, label="Estimate", color="#1f77b4", linewidth=2)
69
+
70
+ # Plot error band
71
+ ax.fill_between(
72
+ n_vals,
73
+ estimate_vals - error_vals,
74
+ estimate_vals + error_vals,
75
+ color="#1f77b4",
76
+ alpha=0.2,
77
+ label="Error Band",
78
+ )
79
+
80
+ # Plot vertical line for convergence (first True occurrence)
81
+ converged_df = df.filter(pl.col(self.result.is_converged_col))
82
+ if not converged_df.is_empty():
83
+ first_converged_n = converged_df.get_column(self.result.n_col)[0]
84
+ ax.axvline(
85
+ x=first_converged_n,
86
+ color="red",
87
+ linestyle="--",
88
+ label=f"Converged at n={first_converged_n}",
89
+ )
90
+
91
+ ax.set_xlabel("Number of samples (n)")
92
+ ax.set_ylabel("Estimate")
93
+ ax.set_title(f"Convergence Evolution ({self.result.method})")
94
+ ax.legend()
95
+ ax.grid(True, linestyle=":", alpha=0.7)
96
+
97
+ if show:
98
+ plt.show()
99
+ plt.close(fig)
100
+
101
+ return ax
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class ConvergenceResult:
106
+ """
107
+ Common result returned by convergence criteria.
108
+
109
+ Parameters
110
+ ----------
111
+ method
112
+ Name of the convergence criterion.
113
+ n
114
+ Number of samples used in the final estimate.
115
+ n_col
116
+ Name of the n column set on the method data output
117
+ estimate
118
+ Final estimate of the target quantity.
119
+ estimate_col
120
+ Name of the estimate column set on the method data output
121
+ error
122
+ Final estimated error.
123
+ error_col
124
+ Name of the error column set on the method data output
125
+ is_converged
126
+ Whether the final evaluated state satisfies the criterion.
127
+ is_converged_col
128
+ Name of the is_converged column set on the method data output
129
+ data
130
+ Full diagnostic DataFrame generated by the method/wrapper.
131
+ diagnostics
132
+ Method-specific diagnostic information.
133
+ """
134
+
135
+ # Common outputs to all criterions
136
+ method: str
137
+ n: int
138
+ n_col: str
139
+ estimate: float | None
140
+ estimate_col: str
141
+ error: float | None
142
+ error_col: str
143
+ is_converged: bool | None
144
+ is_converged_col: str
145
+
146
+ # Build by Wrapper or by the criterion default
147
+ data: pl.DataFrame
148
+
149
+ # Method-specific outputs
150
+ diagnostics: dict[str, Any] = field(default_factory=dict)
151
+
152
+ @property
153
+ def plot(self) -> ResultPlotter:
154
+ """Access the plotting API for this result."""
155
+ return ResultPlotter(self)
156
+
157
+ def to_polars(self) -> pl.DataFrame:
158
+ """Return the complete diagnostic DataFrame as a Polars DataFrame."""
159
+ return self.data
160
+
161
+ def to_pandas(self) -> pd.DataFrame:
162
+ """Return the complete diagnostic DataFrame as a Pandas DataFrame."""
163
+ return self.data.to_pandas()
164
+
165
+ def summary(self) -> None:
166
+ """Print a rich formatted summary of the convergence result to the console."""
167
+ console = Console()
168
+
169
+ # Define status colors and icons
170
+ status_color = "green" if self.is_converged else "yellow"
171
+ status_icon = "✔" if self.is_converged else "⚠"
172
+ status_text = "Converged" if self.is_converged else "Not Converged"
173
+
174
+ # Create the inner layout table
175
+ table = Table(show_header=False, box=None, padding=(0, 2))
176
+ table.add_row("[bold]Method:[/bold]", f"[cyan]{self.method}[/cyan]")
177
+ table.add_row(
178
+ "[bold]Status:[/bold]", f"[{status_color}]{status_icon} {status_text}[/{status_color}]"
179
+ )
180
+ table.add_row("[bold]Samples (n):[/bold]", str(self.n))
181
+ est_str = f"{self.estimate:.6g}" if self.estimate is not None else "-"
182
+ table.add_row("[bold]Estimate:[/bold]", est_str)
183
+ err_str = f"{self.error:.6g}" if self.error is not None else "-"
184
+ table.add_row("[bold]Error:[/bold]", err_str)
185
+
186
+ # Append diagnostics dynamically if they exist
187
+ if self.diagnostics:
188
+ table.add_row("", "") # Spacer
189
+ table.add_row("[bold italic]Diagnostics:[/bold italic]", "")
190
+ for key, value in self.diagnostics.items():
191
+ # Format floats cleanly, otherwise just cast to str
192
+ val_str = f"{value:.6g}" if isinstance(value, float) else str(value)
193
+ table.add_row(f" {key}:", val_str)
194
+
195
+ # Wrap in a stylized panel
196
+ panel = Panel(
197
+ table,
198
+ title="[bold]Convergence Summary[/bold]",
199
+ title_align="left",
200
+ border_style=status_color,
201
+ expand=False,
202
+ )
203
+
204
+ console.print()
205
+ console.print(panel)
206
+ console.print()
207
+
208
+ def __str__(self) -> str:
209
+ """Return the string representation of the underlying Polars DataFrame."""
210
+ return str(self.data)
211
+
212
+ def __repr__(self) -> str:
213
+ """Return a compact string representation of the result summary."""
214
+ return (
215
+ f"ConvergenceResult("
216
+ f"method={self.method!r}, "
217
+ f"n={self.n!r}"
218
+ f"estimate={self.estimate!r}, "
219
+ f"error={self.error!r}, "
220
+ f"is_converged={self.is_converged!r}, "
221
+ f")"
222
+ )
mocaco/wrappers.py ADDED
@@ -0,0 +1,170 @@
1
+ """Criterion wrappers to expand evaluation behaviors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import polars as pl
8
+
9
+ from .protocols import SampleFrame
10
+ from .result import ConvergenceResult
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Sequence
14
+
15
+ from .protocols import Criterion
16
+
17
+
18
+ class CriterionWrapper:
19
+ """
20
+ Base class for criterion wrappers.
21
+
22
+ A wrapper intercepts calls to a criterion's ``run()`` method to add
23
+ cross-cutting behaviour (e.g. periodic evaluation, caching, logging)
24
+ without modifying the criterion itself.
25
+
26
+ All metadata attributes are delegated to the inner criterion so that
27
+ the wrapper satisfies the same ``Criterion`` protocol.
28
+ """
29
+
30
+ def __init__(self, criterion: Criterion) -> None:
31
+ self._criterion = criterion
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ return self._criterion.name
36
+
37
+ @property
38
+ def description(self) -> str:
39
+ return self._criterion.description
40
+
41
+ @property
42
+ def params_type(self) -> type[Any]:
43
+ return self._criterion.params_type
44
+
45
+ @property
46
+ def assumptions(self) -> Sequence[str]:
47
+ return self._criterion.assumptions
48
+
49
+ @property
50
+ def limitations(self) -> Sequence[str]:
51
+ return self._criterion.limitations
52
+
53
+ @property
54
+ def result_interpretation(self) -> str:
55
+ return self._criterion.result_interpretation
56
+
57
+ @property
58
+ def example_usage(self) -> str:
59
+ return self._criterion.example_usage
60
+
61
+ @property
62
+ def references(self) -> Sequence[str]:
63
+ return self._criterion.references
64
+
65
+ def run(
66
+ self,
67
+ samples: SampleFrame,
68
+ params: Any,
69
+ ) -> ConvergenceResult:
70
+ return self._criterion.run(samples, params)
71
+
72
+
73
+ class EvalFrequencyWrapper(CriterionWrapper):
74
+ """
75
+ Wrapper that evaluates a criterion at regular iteration intervals.
76
+
77
+ Instead of running the criterion once on the full dataset, this wrapper
78
+ runs it on cumulative slices of the data at every ``eval_frequency``
79
+ rows. The diagnostic DataFrames from each evaluation are concatenated
80
+ into a single DataFrame, and the final ``ConvergenceResult`` carries the
81
+ last run's summary fields combined with the concatenated history.
82
+
83
+ Parameters
84
+ ----------
85
+ criterion
86
+ The inner criterion to wrap.
87
+ eval_frequency
88
+ Number of rows between successive evaluations. Must be a
89
+ positive integer.
90
+
91
+ Examples
92
+ --------
93
+ >>> wrapper = EvalFrequencyWrapper(criterion, eval_frequency=10)
94
+ >>> result = wrapper.run(samples, params)
95
+ >>> result.data # diagnostic history across all evaluations
96
+ """
97
+
98
+ def __init__(self, criterion: Criterion, eval_frequency: int) -> None:
99
+ super().__init__(criterion)
100
+ if eval_frequency <= 0:
101
+ raise ValueError(f"eval_frequency must be a positive integer, got {eval_frequency}")
102
+ self._eval_frequency = eval_frequency
103
+
104
+ @property
105
+ def eval_frequency(self) -> int:
106
+ return self._eval_frequency
107
+
108
+ def run(
109
+ self,
110
+ samples: SampleFrame,
111
+ params: Any,
112
+ ) -> ConvergenceResult:
113
+ total_rows = len(samples.df)
114
+
115
+ if self._eval_frequency >= total_rows:
116
+ result = self._criterion.run(samples, params)
117
+ return ConvergenceResult(
118
+ method=result.method,
119
+ n=result.n,
120
+ n_col=result.n_col,
121
+ estimate=result.estimate,
122
+ estimate_col=result.estimate_col,
123
+ error=result.error,
124
+ error_col=result.error_col,
125
+ is_converged=result.is_converged,
126
+ is_converged_col=result.is_converged_col,
127
+ data=result.data,
128
+ diagnostics={
129
+ **result.diagnostics,
130
+ "eval_frequency": self._eval_frequency,
131
+ "n_evaluations": 1,
132
+ },
133
+ )
134
+
135
+ eval_points = list(range(self._eval_frequency, total_rows + 1, self._eval_frequency))
136
+ if eval_points[-1] != total_rows:
137
+ eval_points.append(total_rows)
138
+
139
+ results: list[ConvergenceResult] = []
140
+ for point in eval_points:
141
+ sliced_df = samples.df.head(point)
142
+ sliced_samples = SampleFrame(
143
+ df=sliced_df,
144
+ target_col=samples.target_col,
145
+ it_col=samples.it_col,
146
+ )
147
+ result = self._criterion.run(sliced_samples, params)
148
+ results.append(result)
149
+
150
+ data_frames = [r.data for r in results]
151
+ combined_data = pl.concat(data_frames, how="align")
152
+
153
+ final = results[-1]
154
+ return ConvergenceResult(
155
+ method=final.method,
156
+ n=final.n,
157
+ n_col=final.n_col,
158
+ estimate=final.estimate,
159
+ estimate_col=final.estimate_col,
160
+ error=final.error,
161
+ error_col=final.error_col,
162
+ is_converged=final.is_converged,
163
+ is_converged_col=final.is_converged_col,
164
+ data=combined_data,
165
+ diagnostics={
166
+ **final.diagnostics,
167
+ "eval_frequency": self._eval_frequency,
168
+ "n_evaluations": len(results),
169
+ },
170
+ )
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.5
2
+ Name: mocaco
3
+ Version: 0.1.0
4
+ Summary: Convergence analysis for Monte Carlo series — works with Polars DataFrames
5
+ Author-email: Álvaro Esteban Muñoz <em.alvaro.l@gmail.com>
6
+ License: LGPL-3.0-or-later
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: matplotlib>=3.11.2
10
+ Requires-Dist: numpy>=2.5.3
11
+ Requires-Dist: pandas>=3.0.6
12
+ Requires-Dist: polars>=1.44.2
13
+ Requires-Dist: pyarrow>=25.0.1
14
+ Requires-Dist: pydantic>=2.13.5
15
+ Requires-Dist: rich>=15.0.0
16
+ Requires-Dist: scipy>=1.18.1
17
+ Description-Content-Type: text/markdown
18
+
19
+ # mocaco
20
+
21
+ [![CI](https://github.com/emalvarol/mocaco/actions/workflows/ci.yml/badge.svg)](https://github.com/emalvarol/mocaco/actions/workflows/ci.yml)
22
+ ![Python Versions](https://img.shields.io/badge/python-3.12%20|%203.13%20|%203.14-blue)
23
+ ![License: LGPL](https://img.shields.io/badge/license-LGPL-blue)
24
+
25
+ Convergence analysis for Monte Carlo series using Polars DataFrames.
26
+
27
+ <p align="center">
28
+ <img src="assets/Logo_v1.png" alt="mocaco logo" width="400"/>
29
+ </p>
30
+
31
+ The library provides a simple API for applying different convergence and
32
+ stopping criteria to Monte Carlo simulation results.
33
+
34
+ ## Quickstart
35
+
36
+ ```python
37
+ import polars as pl
38
+ import mocaco as mcc
39
+
40
+ # Prepare your Monte Carlo samples
41
+ df = pl.DataFrame({"it": range(1000), "value": your_samples})
42
+
43
+ # Create a normalized sample frame
44
+ samples = mcc.samples(df, it_col="it", target_col="value")
45
+
46
+ # Run a convergence criterion
47
+ result = mcc.convergence.clt_absolute(samples, threshold=0.01)
48
+
49
+ # Inspect results
50
+ print(result.is_converged) # True/False
51
+ print(result.estimate) # Final cumulative mean
52
+ print(result.error) # Final CLT-based absolute error
53
+ print(result.n) # Number of samples
54
+ # and more...
55
+ ```
56
+
57
+ ## API Overview
58
+
59
+ | Function | Description |
60
+ |----------|-------------|
61
+ | `mcc.samples(df, it_col, target_col)` | Create a normalized sample frame from a Polars DataFrame |
62
+ | `mcc.convergence(samples, method="...")` | Generic invocation of any registered criterion |
63
+ | `mcc.convergence.clt_absolute(samples, ...)` | Typed invocation of a specific criterion |
64
+ | `mcc.methods()` | List all registered convergence criteria |
65
+ | `mcc.describe("clt_absolute")` | Get metadata and parameter info for a criterion |
66
+
67
+ ## Available Methods
68
+
69
+ - [`clt_absolute`](docs/methods/clt_absolute.md) — CLT-based absolute error threshold
70
+
71
+ ## Documentation
72
+
73
+ Full documentation is available in the `docs/` directory or can be built with:
74
+
75
+ ```bash
76
+ mkdocs serve
77
+ ```
78
+
79
+ ## Installation (Developers)
80
+
81
+ ### Environment
82
+
83
+ Create the virtual environment and install the project in editable mode with `uv`:
84
+
85
+ ```bash
86
+ uv venv
87
+ uv sync
88
+ uv pip install -e .
89
+ ```
90
+
91
+ If it is needed to remove an old environment:
92
+
93
+ ```bash
94
+ Remove-Item -Recurse -Force .venv
95
+ ```
96
+
97
+ ### ruff
98
+ ```bash
99
+ uv run ruff check --fix . && uv run ruff format .
100
+ ```
101
+
102
+ ### mypy
103
+ ```bash
104
+ uv run mypy src/
105
+ ```
@@ -0,0 +1,13 @@
1
+ mocaco/__init__.py,sha256=tWfOIMSLYMu9gEYcOqpgn9z2MBvi48vSHzyg4oNhDCA,182
2
+ mocaco/api.py,sha256=LEQmSmDVupbUY6A4eKn6AiaPqCRusZ9bLGk8GGf019E,3389
3
+ mocaco/api.pyi,sha256=gTxbV3lDQWn-aObe5CWWn4cQuzigJGAjsUMylgeSiPE,1165
4
+ mocaco/protocols.py,sha256=aSYD-ij3sXFZGvbn7YbSmK3XjTohujHgAkh8RsckgE0,3431
5
+ mocaco/registry.py,sha256=7BzJVuhfihAi5wdihQouR4_mXiF8MOp-oQ2y2C5DLh4,4036
6
+ mocaco/result.py,sha256=qdZ9Mu4OR-W-JZiQqRin2S4jRED6gZji5hRu4i_UIaQ,7068
7
+ mocaco/wrappers.py,sha256=G97_DmlcPlAryxWAjn1DmwyEIS7BN9CtX-6lfjhTIbE,5262
8
+ mocaco/methods/__init__.py,sha256=3Npj5Yu67T8vlTcfxNqwGbtSx2V3mmG0RSHoLFGQCz8,216
9
+ mocaco/methods/clt_absolute.py,sha256=pkcEj8UhwFxdKVyjitXACmF9VVWGZ3K4Blrzy1-jxzE,4195
10
+ mocaco-0.1.0.dist-info/METADATA,sha256=XqCjH_H7bXpOSWwyzvWXvESS4aibiO7OFvjYiu_T1uo,2841
11
+ mocaco-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
12
+ mocaco-0.1.0.dist-info/licenses/LICENSE,sha256=pWgb-bBdsU2Gd2kwAXxketnm5W_2u8_fIeWEgojfrxs,7651
13
+ mocaco-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,165 @@
1
+ GNU LESSER GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+
9
+ This version of the GNU Lesser General Public License incorporates
10
+ the terms and conditions of version 3 of the GNU General Public
11
+ License, supplemented by the additional permissions listed below.
12
+
13
+ 0. Additional Definitions.
14
+
15
+ As used herein, "this License" refers to version 3 of the GNU Lesser
16
+ General Public License, and the "GNU GPL" refers to version 3 of the GNU
17
+ General Public License.
18
+
19
+ "The Library" refers to a covered work governed by this License,
20
+ other than an Application or a Combined Work as defined below.
21
+
22
+ An "Application" is any work that makes use of an interface provided
23
+ by the Library, but which is not otherwise based on the Library.
24
+ Defining a subclass of a class defined by the Library is deemed a mode
25
+ of using an interface provided by the Library.
26
+
27
+ A "Combined Work" is a work produced by combining or linking an
28
+ Application with the Library. The particular version of the Library
29
+ with which the Combined Work was made is also called the "Linked
30
+ Version".
31
+
32
+ The "Minimal Corresponding Source" for a Combined Work means the
33
+ Corresponding Source for the Combined Work, excluding any source code
34
+ for portions of the Combined Work that, considered in isolation, are
35
+ based on the Application, and not on the Linked Version.
36
+
37
+ The "Corresponding Application Code" for a Combined Work means the
38
+ object code and/or source code for the Application, including any data
39
+ and utility programs needed for reproducing the Combined Work from the
40
+ Application, but excluding the System Libraries of the Combined Work.
41
+
42
+ 1. Exception to Section 3 of the GNU GPL.
43
+
44
+ You may convey a covered work under sections 3 and 4 of this License
45
+ without being bound by section 3 of the GNU GPL.
46
+
47
+ 2. Conveying Modified Versions.
48
+
49
+ If you modify a copy of the Library, and, in your modifications, a
50
+ facility refers to a function or data to be supplied by an Application
51
+ that uses the facility (other than as an argument passed when the
52
+ facility is invoked), then you may convey a copy of the modified
53
+ version:
54
+
55
+ a) under this License, provided that you make a good faith effort to
56
+ ensure that, in the event an Application does not supply the
57
+ function or data, the facility still operates, and performs
58
+ whatever part of its purpose remains meaningful, or
59
+
60
+ b) under the GNU GPL, with none of the additional permissions of
61
+ this License applicable to that copy.
62
+
63
+ 3. Object Code Incorporating Material from Library Header Files.
64
+
65
+ The object code form of an Application may incorporate material from
66
+ a header file that is part of the Library. You may convey such object
67
+ code under terms of your choice, provided that, if the incorporated
68
+ material is not limited to numerical parameters, data structure
69
+ layouts and accessors, or small macros, inline functions and templates
70
+ (ten or fewer lines in length), you do both of the following:
71
+
72
+ a) Give prominent notice with each copy of the object code that the
73
+ Library is used in it and that the Library and its use are
74
+ covered by this License.
75
+
76
+ b) Accompany the object code with a copy of the GNU GPL and this license
77
+ document.
78
+
79
+ 4. Combined Works.
80
+
81
+ You may convey a Combined Work under terms of your choice that,
82
+ taken together, effectively do not restrict modification of the
83
+ portions of the Library contained in the Combined Work and reverse
84
+ engineering for debugging such modifications, if you also do each of
85
+ the following:
86
+
87
+ a) Give prominent notice with each copy of the Combined Work that
88
+ the Library is used in it and that the Library and its use are
89
+ covered by this License.
90
+
91
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
92
+ document.
93
+
94
+ c) For a Combined Work that displays copyright notices during
95
+ execution, include the copyright notice for the Library among
96
+ these notices, as well as a reference directing the user to the
97
+ copies of the GNU GPL and this license document.
98
+
99
+ d) Do one of the following:
100
+
101
+ 0) Convey the Minimal Corresponding Source under the terms of this
102
+ License, and the Corresponding Application Code in a form
103
+ suitable for, and under terms that permit, the user to
104
+ recombine or relink the Application with a modified version of
105
+ the Linked Version to produce a modified Combined Work, in the
106
+ manner specified by section 6 of the GNU GPL for conveying
107
+ Corresponding Source.
108
+
109
+ 1) Use a suitable shared library mechanism for linking with the
110
+ Library. A suitable mechanism is one that (a) uses at run time
111
+ a copy of the Library already present on the user's computer
112
+ system, and (b) will operate properly with a modified version
113
+ of the Library that is interface-compatible with the Linked
114
+ Version.
115
+
116
+ e) Provide Installation Information, but only if you would otherwise
117
+ be required to provide such information under section 6 of the
118
+ GNU GPL, and only to the extent that such information is
119
+ necessary to install and execute a modified version of the
120
+ Combined Work produced by recombining or relinking the
121
+ Application with a modified version of the Linked Version. (If
122
+ you use option 4d0, the Installation Information must accompany
123
+ the Minimal Corresponding Source and Corresponding Application
124
+ Code. If you use option 4d1, you must provide the Installation
125
+ Information in the manner specified by section 6 of the GNU GPL
126
+ for conveying Corresponding Source.)
127
+
128
+ 5. Combined Libraries.
129
+
130
+ You may place library facilities that are a work based on the
131
+ Library side by side in a single library together with other library
132
+ facilities that are not Applications and are not covered by this
133
+ License, and convey such a combined library under terms of your
134
+ choice, if you do both of the following:
135
+
136
+ a) Accompany the combined library with a copy of the same work based
137
+ on the Library, uncombined with any other library facilities,
138
+ conveyed under the terms of this License.
139
+
140
+ b) Give prominent notice with the combined library that part of it
141
+ is a work based on the Library, and explaining where to find the
142
+ accompanying uncombined form of the same work.
143
+
144
+ 6. Revised Versions of the GNU Lesser General Public License.
145
+
146
+ The Free Software Foundation may publish revised and/or new versions
147
+ of the GNU Lesser General Public License from time to time. Such new
148
+ versions will be similar in spirit to the present version, but may
149
+ differ in detail to address new problems or concerns.
150
+
151
+ Each version is given a distinguishing version number. If the
152
+ Library as you received it specifies that a certain numbered version
153
+ of the GNU Lesser General Public License "or any later version"
154
+ applies to it, you have the option of following the terms and
155
+ conditions either of that published version or of any later version
156
+ published by the Free Software Foundation. If the Library as you
157
+ received it does not specify a version number of the GNU Lesser
158
+ General Public License, you may choose any version of the GNU Lesser
159
+ General Public License ever published by the Free Software Foundation.
160
+
161
+ If the Library as you received it specifies that a proxy can decide
162
+ whether future versions of the GNU Lesser General Public License shall
163
+ apply, that proxy's public statement of acceptance of any version is
164
+ permanent authorization for you to choose that version for the
165
+ Library.