Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion pySEQTarget/SEQoutput.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}")
31 changes: 24 additions & 7 deletions pySEQTarget/SEQuential.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 1 addition & 2 deletions pySEQTarget/analysis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions pySEQTarget/helpers/__init__.py
Original file line number Diff line number Diff line change
@@ -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
167 changes: 167 additions & 0 deletions pySEQTarget/helpers/_output_files.py
Original file line number Diff line number Diff line change
@@ -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"""
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>{html_content}</body>
</html>
"""

HTML(string=full_html).write_pdf(filename, stylesheets=[css])
28 changes: 14 additions & 14 deletions pySEQTarget/plot/_survival_plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand All @@ -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):
Expand Down
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -65,6 +65,12 @@ dev = [
"sphinx-autodoc-typehints",
]

output = [
"markdown",
"weasyprint",
"tabulate"
]

[tool.setuptools.packages.find]
where = ["."]
include = ["pySEQTarget*"]
Expand Down