diff --git a/docs/conf.py b/docs/conf.py index 0fe672d..a7f536b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,6 +4,7 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html import importlib.metadata + # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information import os diff --git a/pySEQTarget/SEQoutput.py b/pySEQTarget/SEQoutput.py index 8919fc3..c1c4899 100644 --- a/pySEQTarget/SEQoutput.py +++ b/pySEQTarget/SEQoutput.py @@ -1,10 +1,13 @@ +import tempfile from dataclasses import dataclass +from pathlib import Path from typing import List, Literal, Optional import matplotlib.figure import polars as pl from statsmodels.base.wrapper import ResultsWrapper +from .helpers import _build_md, _build_pdf from .SEQopts import SEQopts @@ -47,7 +50,7 @@ class SEQoutput: denominator_models: List[ResultsWrapper] = None outcome_models: List[List[ResultsWrapper]] = None compevent_models: List[List[ResultsWrapper]] = None - weight_statistics: dict = None + weight_statistics: pl.DataFrame = None hazard: pl.DataFrame = None km_data: pl.DataFrame = None km_graph: matplotlib.figure.Figure = None @@ -128,3 +131,33 @@ def retrieve_data( if data is None: raise ValueError("Data {type} was not created in the SEQuential process") return data + + def to_md(self, filename="SEQuential_results.md") -> None: + """Generates a markdown report of the SEQuential analysis results.""" + + img_path = None + if self.options.km_curves and self.km_graph is not None: + img_path = Path(filename).with_suffix(".png") + self.km_graph.savefig(img_path, dpi=300, bbox_inches="tight") + img_path = img_path.name + + with open(filename, "w") as f: + f.write(_build_md(self, img_path)) + + print(f"Results saved to {filename}") + + def to_pdf(self, filename="SEQuential_results.pdf") -> None: + """Generates a PDF report of the SEQuential analysis results.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmp_md = Path(tmpdir) / "report.md" + self.to_md(str(tmp_md)) + + with open(tmp_md, "r") as f: + md_content = f.read() + + tmp_img = tmp_md.with_suffix(".png") + img_abs_path = str(tmp_img.absolute()) if tmp_img.exists() else None + + _build_pdf(md_content, filename, img_abs_path) + + print(f"Results saved to {filename}") diff --git a/pySEQTarget/SEQuential.py b/pySEQTarget/SEQuential.py index b068dc9..d13685f 100644 --- a/pySEQTarget/SEQuential.py +++ b/pySEQTarget/SEQuential.py @@ -7,19 +7,36 @@ import numpy as np import polars as pl -from .analysis import (_calculate_hazard, _calculate_survival, _outcome_fit, - _pred_risk, _risk_estimates, _subgroup_fit) +from .analysis import ( + _calculate_hazard, + _calculate_survival, + _outcome_fit, + _pred_risk, + _risk_estimates, + _subgroup_fit, +) from .error import _datachecker, _param_checker from .expansion import _binder, _diagnostics, _dynamic, _random_selection from .helpers import _col_string, _format_time, bootstrap_loop -from .initialization import (_cense_denominator, _cense_numerator, - _denominator, _numerator, _outcome) +from .initialization import ( + _cense_denominator, + _cense_numerator, + _denominator, + _numerator, + _outcome, +) from .plot import _survival_plot from .SEQopts import SEQopts from .SEQoutput import SEQoutput -from .weighting import (_fit_denominator, _fit_LTFU, _fit_numerator, - _weight_bind, _weight_predict, _weight_setup, - _weight_stats) +from .weighting import ( + _fit_denominator, + _fit_LTFU, + _fit_numerator, + _weight_bind, + _weight_predict, + _weight_setup, + _weight_stats, +) class SEQuential: diff --git a/pySEQTarget/analysis/__init__.py b/pySEQTarget/analysis/__init__.py index 6799dfd..e35ceb7 100644 --- a/pySEQTarget/analysis/__init__.py +++ b/pySEQTarget/analysis/__init__.py @@ -3,6 +3,5 @@ from ._risk_estimates import _risk_estimates as _risk_estimates from ._subgroup_fit import _subgroup_fit as _subgroup_fit from ._survival_pred import _calculate_survival as _calculate_survival -from ._survival_pred import \ - _get_outcome_predictions as _get_outcome_predictions +from ._survival_pred import _get_outcome_predictions as _get_outcome_predictions from ._survival_pred import _pred_risk as _pred_risk diff --git a/pySEQTarget/helpers/__init__.py b/pySEQTarget/helpers/__init__.py index f621544..f45531a 100644 --- a/pySEQTarget/helpers/__init__.py +++ b/pySEQTarget/helpers/__init__.py @@ -1,6 +1,8 @@ from ._bootstrap import bootstrap_loop as bootstrap_loop from ._col_string import _col_string as _col_string from ._format_time import _format_time as _format_time +from ._output_files import _build_md as _build_md +from ._output_files import _build_pdf as _build_pdf from ._pad import _pad as _pad from ._predict_model import _predict_model as _predict_model from ._prepare_data import _prepare_data as _prepare_data diff --git a/pySEQTarget/helpers/_output_files.py b/pySEQTarget/helpers/_output_files.py new file mode 100644 index 0000000..69c0e16 --- /dev/null +++ b/pySEQTarget/helpers/_output_files.py @@ -0,0 +1,167 @@ +import datetime + + +def _build_md(self, img_path: str = None) -> str: + """ + Builds markdown content for SEQuential analysis results. + + :param self: SEQoutput instance + :param img_path: Path to saved KM graph image (if any) + :return: Markdown string + """ + + lines = [] + + lines.append(f"# SEQuential Analysis: {datetime.date.today()}: {self.method}") + lines.append("") + + if self.options.weighted: + lines.append("## Weighting") + lines.append("") + + lines.append("### Numerator Model") + lines.append("") + lines.append("```") + lines.append(str(self.numerator_models[0].summary())) + lines.append("```") + lines.append("") + + lines.append("### Denominator Model") + lines.append("") + lines.append("```") + lines.append(str(self.denominator_models[0].summary())) + lines.append("```") + lines.append("") + + if self.options.compevent_colname is not None and self.compevent_models: + lines.append("### Competing Event Model") + lines.append("") + lines.append("```") + lines.append(str(self.compevent_models[0].summary())) + lines.append("```") + lines.append("") + + lines.append("### Weighting Statistics") + lines.append("") + lines.append(self.weight_statistics.to_pandas().to_markdown(index=False)) + lines.append("") + + lines.append("## Outcome") + lines.append("") + + lines.append("### Outcome Model") + lines.append("") + lines.append("```") + lines.append(str(self.outcome_models[0].summary())) + lines.append("```") + lines.append("") + + if self.options.hazard_estimate and self.hazard is not None: + lines.append("### Hazard") + lines.append("") + lines.append(self.hazard.to_pandas().to_markdown(index=False)) + lines.append("") + + if self.options.km_curves: + lines.append("### Survival") + lines.append("") + + if self.risk_difference is not None: + lines.append("#### Risk Differences") + lines.append("") + lines.append(self.risk_difference.to_pandas().to_markdown(index=False)) + lines.append("") + + if self.risk_ratio is not None: + lines.append("#### Risk Ratios") + lines.append("") + lines.append(self.risk_ratio.to_pandas().to_markdown(index=False)) + lines.append("") + + if self.km_graph is not None and img_path is not None: + lines.append("#### Survival Curves") + lines.append("") + lines.append(f"![Kaplan-Meier Survival Curves]({img_path})") + lines.append("") + + if self.diagnostic_tables: + lines.append("## Diagnostic Tables") + lines.append("") + for name, table in self.diagnostic_tables.items(): + lines.append(f"### {name.replace('_', ' ').title()}") + lines.append("") + lines.append(table.to_pandas().to_markdown(index=False)) + lines.append("") + + return "\n".join(lines) + + +def _build_pdf(md_content: str, filename: str, img_path: str = None) -> None: + """ + Converts markdown content to PDF. + + :param md_content: Markdown string + :param filename: Output PDF path + :param img_path: Absolute path to image file (if any) + """ + try: + import markdown + from weasyprint import CSS, HTML + except ImportError: + raise ImportError( + "PDF generation requires 'markdown' and 'weasyprint'. " + "Install with: pip install markdown weasyprint" + ) + + html_content = markdown.markdown(md_content, extensions=["tables", "fenced_code"]) + + if img_path: + img_name = img_path.split("/")[-1] + html_content = html_content.replace( + f'src="{img_name}"', f'src="file://{img_path}"' + ) + + css = CSS( + string=""" + body { + font-family: Arial, sans-serif; + font-size: 11pt; + line-height: 1.4; + margin: 2cm; + } + h1 { color: #2c3e50; border-bottom: 2px solid #2c3e50; padding-bottom: 0.3em; } + h2 { color: #34495e; border-bottom: 1px solid #bdc3c7; padding-bottom: 0.2em; } + h3 { color: #7f8c8d; } + table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; + } + th, td { + border: 1px solid #bdc3c7; + padding: 8px; + text-align: left; + } + th { background-color: #ecf0f1; } + tr:nth-child(even) { background-color: #f9f9f9; } + pre { + background-color: #f4f4f4; + padding: 1em; + border-radius: 4px; + overflow-x: auto; + font-size: 9pt; + } + code { font-family: 'Courier New', monospace; } + img { max-width: 100%; height: auto; } + """ + ) + + full_html = f""" + + + + {html_content} + + """ + + HTML(string=full_html).write_pdf(filename, stylesheets=[css]) diff --git a/pySEQTarget/plot/_survival_plot.py b/pySEQTarget/plot/_survival_plot.py index 0592036..1de8752 100644 --- a/pySEQTarget/plot/_survival_plot.py +++ b/pySEQTarget/plot/_survival_plot.py @@ -14,30 +14,32 @@ def _survival_plot(self): plot_data = self.km_data.filter(pl.col("estimate") == "incidence") if self.subgroup_colname is None: - _plot_single(self, plot_data) + fig = _plot_single(self, plot_data) else: - _plot_subgroups(self, plot_data) + fig = _plot_subgroups(self, plot_data) + + return fig def _plot_single(self, plot_data): - plt.figure(figsize=(10, 6)) - _plot_data(self, plot_data, plt.gca()) + fig, ax = plt.subplots(figsize=(10, 6)) + _plot_data(self, plot_data, ax) if self.plot_title is None: self.plot_title = f"Cumulative {self.plot_type.title()}" - plt.xlabel("Followup") - plt.ylabel(self.plot_type.title()) - plt.title(self.plot_title) - plt.legend() - plt.grid() - plt.show() + ax.set_xlabel("Followup") + ax.set_ylabel(self.plot_type.title()) + ax.set_title(self.plot_title) + ax.legend() + ax.grid() + + return fig def _plot_subgroups(self, plot_data): subgroups = sorted(plot_data[self.subgroup_colname].unique().to_list()) n_subgroups = len(subgroups) - n_cols = min(3, n_subgroups) n_rows = (n_subgroups + n_cols - 1) // n_cols @@ -48,11 +50,9 @@ def _plot_subgroups(self, plot_data): ax = axes[idx] subgroup_data = plot_data.filter(pl.col(self.subgroup_colname) == subgroup_val) _plot_data(self, subgroup_data, ax) - subgroup_label = ( str(subgroup_val).title() if isinstance(subgroup_val, str) else subgroup_val ) - ax.set_xlabel("Followup") ax.set_ylabel(self.plot_type.title()) ax.set_title( @@ -72,7 +72,7 @@ def _plot_subgroups(self, plot_data): fig.suptitle(f"Cumulative {self.plot_type.title()}", fontsize=14) plt.tight_layout() - plt.show() + return fig def _plot_data(self, plot_data, ax): diff --git a/pyproject.toml b/pyproject.toml index 43c8341..b35eb1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pySEQTarget" -version = "0.9.1" +version = "0.9.2" description = "Sequentially Nested Target Trial Emulation" readme = "README.md" license = {text = "MIT"} @@ -65,6 +65,12 @@ dev = [ "sphinx-autodoc-typehints", ] +output = [ + "markdown", + "weasyprint", + "tabulate" +] + [tool.setuptools.packages.find] where = ["."] include = ["pySEQTarget*"]