Source code for qca.mlqca.results

"""Result objects for machine-learning-enhanced QCA workflows."""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import pandas as pd

from qca.results import QCAFitResult


[docs] @dataclass(frozen=True) class MLQCAResult: """Unified result container populated across mlQCA workflow phases.""" feature_importance: pd.DataFrame = field(default_factory=pd.DataFrame) cutoff_candidates: pd.DataFrame = field(default_factory=pd.DataFrame) calibration_proposals: pd.DataFrame = field(default_factory=pd.DataFrame) condition_ranking: pd.DataFrame = field(default_factory=pd.DataFrame) candidate_models: pd.DataFrame = field(default_factory=pd.DataFrame) qca_results: tuple[QCAFitResult | None, ...] = () best_model_id: int | str | None = None pareto_models: pd.DataFrame = field(default_factory=pd.DataFrame) settings: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: table_fields = ( "feature_importance", "cutoff_candidates", "calibration_proposals", "condition_ranking", "candidate_models", "pareto_models", ) for name in table_fields: value = getattr(self, name) if not isinstance(value, pd.DataFrame): raise TypeError(f"{name} must be a pandas DataFrame.") object.__setattr__(self, name, value.copy()) object.__setattr__(self, "qca_results", tuple(self.qca_results)) object.__setattr__(self, "settings", dict(self.settings)) object.__setattr__(self, "metadata", dict(self.metadata)) if self.best_model_id is not None and self.candidate_models.empty: raise ValueError( "best_model_id cannot be set when candidate_models is empty." ) @property def best_qca_result(self) -> QCAFitResult | None: """Return the QCA result selected by ``best_model_id``.""" if self.best_model_id is None: return None return self.get_qca_result(self.best_model_id) @property def n_models(self) -> int: return len(self.candidate_models) @property def n_valid_models(self) -> int: if "valid" not in self.candidate_models: return sum(result is not None for result in self.qca_results) return int(self.candidate_models["valid"].fillna(False).astype(bool).sum())
[docs] def summary(self) -> pd.DataFrame: """Return the candidate-model summary table.""" return self.candidate_models.copy()
[docs] def to_dataframe(self) -> pd.DataFrame: """Alias for :meth:`summary`.""" return self.summary()
[docs] def get_qca_result(self, model_id: int | str) -> QCAFitResult | None: """Return one QCA result by model identifier.""" if self.candidate_models.empty: raise KeyError(f"Unknown mlQCA model_id {model_id!r}.") if "model_id" in self.candidate_models: matches = self.candidate_models.index[ self.candidate_models["model_id"] == model_id ].tolist() if not matches: raise KeyError(f"Unknown mlQCA model_id {model_id!r}.") position = self.candidate_models.index.get_loc(matches[0]) elif isinstance(model_id, int) and 0 <= model_id < len(self.candidate_models): position = model_id else: raise KeyError(f"Unknown mlQCA model_id {model_id!r}.") if position >= len(self.qca_results): return None return self.qca_results[position]
[docs] def to_markdown(self, path: str | Path | None = None) -> str: """Render a compact Markdown report and optionally write it.""" lines = ["# mlQCA Result", "", "## Settings", ""] if self.settings: lines.extend(f"- `{key}`: {value}" for key, value in self.settings.items()) else: lines.append("- No settings recorded") lines.extend( [ "", "## Summary", "", f"- `n_models`: {self.n_models}", f"- `n_valid_models`: {self.n_valid_models}", f"- `best_model_id`: {self.best_model_id}", ] ) for title, table in ( ("Condition Ranking", self.condition_ranking), ("Cutoff Candidates", self.cutoff_candidates), ("Calibration Proposals", self.calibration_proposals), ("Candidate Models", self.candidate_models), ("Pareto Models", self.pareto_models), ): lines.extend(["", f"## {title}", "", _dataframe_to_markdown(table)]) markdown = "\n".join(lines) if path is not None: Path(path).write_text(markdown, encoding="utf-8") return markdown
[docs] def to_markdown_report( self, path: str | Path | None = None, *, title: str = "mlQCA Analysis Report", max_rows: int = 20, ) -> str: """Render a complete mlQCA report with reproducibility metadata.""" from qca.mlqca.reporting import generate_mlqca_markdown_report return generate_mlqca_markdown_report( self, path=path, title=title, max_rows=max_rows, )
[docs] def to_jupyter_summary(self): """Return a notebook-friendly summary object.""" from qca.reporting import jupyter_summary return jupyter_summary(self, title="mlQCA Summary")
def _repr_html_(self) -> str: return self.to_jupyter_summary()._repr_html_()
[docs] def plot_importance( self, *, top_n: int = 15, metric: str = "shap_mean_abs", path: str | Path | None = None, ): """Plot feature importance using optional Matplotlib.""" from qca.mlqca.viz import plot_mlqca_importance return plot_mlqca_importance( self, top_n=top_n, metric=metric, path=path, )
[docs] def plot_cutoffs( self, feature: str, *, path: str | Path | None = None, ): """Plot split-threshold frequency for one condition.""" from qca.mlqca.viz import plot_mlqca_cutoffs return plot_mlqca_cutoffs(self, feature, path=path)
[docs] def plot_frontier(self, *, path: str | Path | None = None): """Plot candidate models and the Pareto frontier.""" from qca.mlqca.viz import plot_mlqca_frontier return plot_mlqca_frontier(self, path=path)
def _dataframe_to_markdown(df: pd.DataFrame) -> str: if df.empty: return "No rows." columns = [str(column) for column in df.columns] header = "| " + " | ".join(columns) + " |" separator = "| " + " | ".join("---" for _ in columns) + " |" rows = [ "| " + " | ".join(_format_cell(value) for value in row) + " |" for row in df.itertuples(index=False, name=None) ] return "\n".join([header, separator, *rows]) def _format_cell(value: Any) -> str: if value is None or value is pd.NA: return "" if isinstance(value, float): if pd.isna(value): return "" return f"{value:.4f}" return str(value).replace("|", "\\|") __all__ = ["MLQCAResult"]