From 3f2e7fc880ad63264aaa06ba92e5c6a4c856ee32 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Mon, 18 Nov 2024 13:26:09 -0500 Subject: [PATCH] UI review; 24.1a6 (#81) * start 24.1a6.dev * set max of 8 degree on multitumor model w/ auto degree set * remove fixed_lsc from excel report * add back fixed_lsc * begin refactor of input data * aggregate summary * nd outputs * add mscombo results * make results optional * change version to 24.1a6 * update todo --- src/bmdscore/bmds_helper.cpp | 2 +- src/pybmds/__init__.py | 2 +- src/pybmds/batch.py | 6 +-- src/pybmds/models/multi_tumor.py | 18 +++++-- src/pybmds/models/nested_dichotomous.py | 7 ++- src/pybmds/reporting/styling.py | 12 ++--- src/pybmds/types/continuous.py | 25 ++++----- src/pybmds/types/dichotomous.py | 23 +++++--- src/pybmds/types/multi_tumor.py | 8 ++- src/pybmds/types/nested_dichotomous.py | 71 ++++++++++++++++++------- src/pybmds/utils.py | 4 ++ tests/test_pybmds/test_utils.py | 13 ++++- 12 files changed, 131 insertions(+), 60 deletions(-) diff --git a/src/bmdscore/bmds_helper.cpp b/src/bmdscore/bmds_helper.cpp index ea2776b9..e08c6e23 100644 --- a/src/bmdscore/bmds_helper.cpp +++ b/src/bmdscore/bmds_helper.cpp @@ -15,7 +15,7 @@ #include "bmds_helper.h" // calendar versioning; see https://peps.python.org/pep-0440/#pre-releases -std::string BMDS_VERSION = "24.1a4"; +std::string BMDS_VERSION = "24.1a6"; double python_dichotomous_model_result::getSRAtDose(double targetDose, std::vector doses) { std::vector diff; diff --git a/src/pybmds/__init__.py b/src/pybmds/__init__.py index 07b2fcbb..211d2408 100644 --- a/src/pybmds/__init__.py +++ b/src/pybmds/__init__.py @@ -1,4 +1,4 @@ -__version__ = "24.1a5" # see docs/development for versioning +__version__ = "24.1a6" # see docs/development for versioning from .batch import BatchResponse, BatchSession # noqa: F401 from .constants import DistType as ContinuousDistType # noqa: F401 diff --git a/src/pybmds/batch.py b/src/pybmds/batch.py index 3a886303..82a3e63f 100644 --- a/src/pybmds/batch.py +++ b/src/pybmds/batch.py @@ -277,11 +277,7 @@ def to_docx( report = Report.build_default() for session in self.sessions: - session.to_docx( - report, - header_level=header_level, - citation=False, - ) + session.to_docx(report, header_level=header_level, citation=False, **kw) if citation and len(self.sessions) > 0: write_citation(report, header_level=header_level) diff --git a/src/pybmds/models/multi_tumor.py b/src/pybmds/models/multi_tumor.py index 92a55967..505c6c49 100644 --- a/src/pybmds/models/multi_tumor.py +++ b/src/pybmds/models/multi_tumor.py @@ -23,6 +23,7 @@ from ..types.multi_tumor import MultitumorAnalysis, MultitumorResult, MultitumorSettings from ..types.priors import multistage_cancer_prior from ..types.session import VersionSchema +from ..utils import unique_items from .dichotomous import MultistageCancer @@ -102,9 +103,15 @@ def write_docx_inputs_table(report: Report, session): hdr = report.styles.tbl_header body = report.styles.tbl_body - rows = session.models[0][0].settings.docx_table_data() + settings = [models[len(models) - 1].settings for models in session.models] + rows = { + "Setting": "Value", + "BMR": unique_items(settings, "bmr_text"), + "Confidence Level (one sided)": unique_items(settings, "confidence_level"), + "Maximum Degree": unique_items(settings, "degree"), + } tbl = report.document.add_table(len(rows), 2, style=styles.table) - for idx, (key, value) in enumerate(rows): + for idx, (key, value) in enumerate(rows.items()): write_cell(tbl.cell(idx, 0), key, style=hdr) write_cell(tbl.cell(idx, 1), value, style=hdr if idx == 0 else body) @@ -177,7 +184,9 @@ def _build_model_settings(self) -> list[list[DichotomousModelSettings]]: ds_settings = [] degree_i = self.degrees[i] degrees_i = ( - range(degree_i, degree_i + 1) if degree_i > 0 else range(1, dataset.num_dose_groups) + range(degree_i, degree_i + 1) + if degree_i > 0 + else range(1, min(dataset.num_dose_groups, 9)) # max of 8 if degree is 0 (auto) ) for degree in degrees_i: model_settings = self.settings.model_copy( @@ -454,6 +463,7 @@ def to_docx( dataset_format_long: bool = True, all_models: bool = False, bmd_cdf_table: bool = False, + **kw, ): """Return a Document object with the session executed @@ -490,6 +500,8 @@ def to_docx( report.document.add_paragraph("Maximum Likelihood Approach", h2) write_docx_frequentist_table(report, self) report.document.add_paragraph(add_mpl_figure(report.document, self.plot(), 6)) + report.document.add_paragraph(self.results.ms_combo_text(), report.styles.fixed_width) + report.document.add_paragraph("Individual Model Results", h2) for dataset, selected_idx, models in zip( diff --git a/src/pybmds/models/nested_dichotomous.py b/src/pybmds/models/nested_dichotomous.py index 726b9d84..d406e228 100644 --- a/src/pybmds/models/nested_dichotomous.py +++ b/src/pybmds/models/nested_dichotomous.py @@ -88,7 +88,7 @@ def get_priors_list(self) -> list[list]: return self.settings.priors.priors_list(nphi=self.dataset.num_dose_groups) def model_settings_text(self) -> str: - input_tbl = self.settings.tbl() + input_tbl = self.settings.tbl(self.results) return multi_lstrip( f""" Input Summary: @@ -142,13 +142,16 @@ class Nctr(BmdModelNestedDichotomous): bmd_model_class = NestedDichotomousModelChoices.nctr.value model_class = bmdscore.nested_model.nctr + def execute(self) -> NestedDichotomousResult: + raise NotImplementedError("TODO - future release") + def get_param_names(self) -> list[str]: return ["a", "b", "theta1", "theta2", "rho"] + [ f"phi{i}" for i in range(1, self.dataset.num_dose_groups + 1) ] def dr_curve(self, doses: np.ndarray, params: dict, fixed_lsc: float) -> np.ndarray: - raise NotImplementedError("TODO - update formula") + raise NotImplementedError("TODO - future release") def get_default_prior_class(self) -> PriorClass: return PriorClass.frequentist_restricted diff --git a/src/pybmds/reporting/styling.py b/src/pybmds/reporting/styling.py index 4a4b53df..e3015a3b 100644 --- a/src/pybmds/reporting/styling.py +++ b/src/pybmds/reporting/styling.py @@ -306,13 +306,11 @@ def write_inputs_table(report: Report, session: Session): hdr = report.styles.tbl_header body = report.styles.tbl_body - model_index = 0 - if hasattr(session.models[0].settings, "degree"): - degrees = [model.settings.degree for model in session.models] - model_index = degrees.index(max(degrees)) - rows = session.models[model_index].settings.docx_table_data() - tbl = report.document.add_table(len(rows), 2, style=styles.table) - for idx, (key, value) in enumerate(rows): + results = session.models[0].results if len(session.models) > 0 else None + settings = [model.settings for model in session.models] + content = session.models[0].settings.docx_table_data(settings, results) + tbl = report.document.add_table(len(content), 2, style=styles.table) + for idx, (key, value) in enumerate(content.items()): write_cell(tbl.cell(idx, 0), key, style=hdr) write_cell(tbl.cell(idx, 1), value, style=hdr if idx == 0 else body) diff --git a/src/pybmds/types/continuous.py b/src/pybmds/types/continuous.py index 4a9b5d16..8c7e4edc 100644 --- a/src/pybmds/types/continuous.py +++ b/src/pybmds/types/continuous.py @@ -7,7 +7,7 @@ from .. import bmdscore, constants from ..constants import BOOL_YES_NO, ContinuousModelChoices, Dtype from ..datasets.continuous import ContinuousDatasets -from ..utils import multi_lstrip, pretty_table +from ..utils import multi_lstrip, pretty_table, unique_items from .common import ( BOUND_FOOTNOTE, CONTINUOUS_TEST_FOOTNOTES, @@ -100,17 +100,18 @@ def tbl(self, show_degree: bool = True) -> str: return pretty_table(data, "") - def docx_table_data(self) -> list: - data = [ - ["Setting", "Value"], - ["BMR", self.bmr_text], - ["Distribution", self.distribution], - ["Adverse Direction", self.direction], - ["Maximum Polynomial Degree", self.degree], - ["Confidence Level (one sided)", self.confidence_level], - ] - if self.is_hybrid: - data.append(["Tail Probability", self.tail_prob]) + @classmethod + def docx_table_data(cls, settings: list[Self], results) -> dict: + data = { + "Setting": "Value", + "BMR": unique_items(settings, "bmr_text"), + "Distribution": unique_items(settings, "distribution"), + "Adverse Direction": unique_items(settings, "direction"), + "Maximum Polynomial Degree": str(max(setting.degree for setting in settings)), + "Confidence Level (one sided)": unique_items(settings, "confidence_level"), + } + if settings[0].is_hybrid: + data["Tail Probability"] = unique_items(settings, "tail_prob") return data def update_record(self, d: dict) -> None: diff --git a/src/pybmds/types/dichotomous.py b/src/pybmds/types/dichotomous.py index 0149d180..eac1cfab 100644 --- a/src/pybmds/types/dichotomous.py +++ b/src/pybmds/types/dichotomous.py @@ -7,7 +7,7 @@ from .. import bmdscore, constants from ..constants import BOOL_YES_NO, DichotomousModelChoices from ..datasets import DichotomousDataset -from ..utils import multi_lstrip, pretty_table +from ..utils import multi_lstrip, pretty_table, unique_items from .common import ( BOUND_FOOTNOTE, NumpyFloatArray, @@ -69,13 +69,20 @@ def tbl(self, show_degree: bool = True) -> str: return pretty_table(data, "") - def docx_table_data(self) -> list: - return [ - ["Setting", "Value"], - ["BMR", self.bmr_text], - ["Confidence Level (one sided)", self.confidence_level], - ["Maximum Multistage Degree", self.degree], - ] + @classmethod + def docx_table_data(cls, settings: list[Self], results) -> dict: + data = { + "Setting": "Value", + "BMR": unique_items(settings, "bmr_text"), + "Confidence Level (one sided)": unique_items(settings, "confidence_level"), + "Maximum Multistage Degree": str(max(setting.degree for setting in settings)), + } + if settings[0].priors.is_bayesian: + data.update( + Samples=unique_items(settings, "samples"), + **{"Burn-in": unique_items(settings, "burnin")}, + ) + return data def update_record(self, d: dict) -> None: """Update data record for a tabular-friendly export""" diff --git a/src/pybmds/types/multi_tumor.py b/src/pybmds/types/multi_tumor.py index c563e4b9..fced49f5 100644 --- a/src/pybmds/types/multi_tumor.py +++ b/src/pybmds/types/multi_tumor.py @@ -4,7 +4,7 @@ from .. import bmdscore from ..models.dichotomous import BmdModelDichotomousSchema -from ..utils import multi_lstrip, pretty_table +from ..utils import get_version, multi_lstrip, pretty_table from .common import inspect_cpp_obj from .dichotomous import ( DichotomousAnalysisCPPStructs, @@ -95,6 +95,12 @@ def text(self, datasets, models) -> str: """ ) + def ms_combo_text(self) -> str: + title = "Multitumor MS Combo Model".center(30) + "\n══════════════════════════════" + version = get_version() + version = f"Version: pybmds {version.python} (bmdscore {version.dll})" + return "\n\n".join([title, version, self.tbl()]) + "\n" + def tbl(self) -> str: data = [ ["BMD", self.bmd], diff --git a/src/pybmds/types/nested_dichotomous.py b/src/pybmds/types/nested_dichotomous.py index d8eace48..e0750793 100644 --- a/src/pybmds/types/nested_dichotomous.py +++ b/src/pybmds/types/nested_dichotomous.py @@ -7,7 +7,7 @@ from .. import bmdscore, constants from ..datasets import NestedDichotomousDataset -from ..utils import camel_to_title, multi_lstrip, pretty_table +from ..utils import camel_to_title, multi_lstrip, pretty_table, unique_items from .common import NumpyFloatArray, clean_array, inspect_cpp_obj from .priors import ModelPriors, PriorClass @@ -22,6 +22,13 @@ class LitterSpecificCovariate(IntEnum): OverallMean = 1 ControlGroupMean = 2 + def label(self, fixed_lsc: float | None) -> str: + text = camel_to_title(self.name) + if self == self.Unused: + return text + lsc = f" ({fixed_lsc:.3f})" if fixed_lsc else "" + return f"{text}{lsc}" + @property def text(self) -> str: return "lsc-" if self == self.Unused else "lsc+" @@ -31,6 +38,10 @@ class IntralitterCorrelation(IntEnum): Zero = 0 Estimate = 1 + @property + def label(self) -> str: + return camel_to_title(self.name) + @property def text(self) -> str: return "ilc+" if self == self.Estimate else "ilc-" @@ -69,25 +80,47 @@ def confidence_level(self) -> float: def modeling_approach(self) -> str: return "MLE" - def _tbl_rows(self) -> list: - return [ - ["BMR", self.bmr_text], - ["Confidence Level (one sided)", self.confidence_level], - ["Litter Specific Covariate", camel_to_title(self.litter_specific_covariate.name)], - ["Intralitter Correlation", self.intralitter_correlation.name], - ["Estimate Background", self.estimate_background], - ["Bootstrap Runs", self.bootstrap_n], - ["Bootstrap Iterations", self.bootstrap_iterations], - ["Bootstrap Seed", self.bootstrap_seed], - ] - - def tbl(self, degree_required: bool = False) -> str: - return pretty_table(self._tbl_rows(), "") + def tbl(self, results=None) -> str: + fixed_lsc = results.fixed_lsc if results else None + return pretty_table( + [ + ["BMR", self.bmr_text], + ["Confidence Level (one sided)", self.confidence_level], + ["Litter Specific Covariate", self.litter_specific_covariate.label(fixed_lsc)], + ["Intralitter Correlation", self.intralitter_correlation.label], + ["Estimate Background", self.estimate_background], + ["Bootstrap Runs", self.bootstrap_n], + ["Bootstrap Iterations", self.bootstrap_iterations], + ["Bootstrap Seed", self.bootstrap_seed], + ], + "", + ) - def docx_table_data(self) -> list: - rows = self._tbl_rows() - rows.insert(0, ["Setting", "Value"]) - return rows + @classmethod + def docx_table_data(cls, settings: list[Self], results) -> dict: + fixed_lsc = results.fixed_lsc if results else None + lsc = [ + setting.litter_specific_covariate.label(fixed_lsc) + for setting in settings + if setting.litter_specific_covariate != LitterSpecificCovariate.Unused + ] + ilc = [ + setting.intralitter_correlation.label + for setting in settings + if setting.intralitter_correlation != IntralitterCorrelation.Zero + ] + data = { + "Setting": "Value", + "BMR": unique_items(settings, "bmr_text"), + "Confidence Level (one sided)": unique_items(settings, "confidence_level"), + "Litter Specific Covariate": ", ".join(sorted(set(lsc))), + "Intralitter Correlation": ", ".join(sorted(set(ilc))), + "Estimate Background": unique_items(settings, "estimate_background"), + "Bootstrap Runs": unique_items(settings, "bootstrap_n"), + "Bootstrap Seed": unique_items(settings, "bootstrap_seed"), + "Bootstrap Iterations": unique_items(settings, "bootstrap_iterations"), + } + return data def update_record(self, d: dict) -> None: """Update data record for a tabular-friendly export""" diff --git a/src/pybmds/utils.py b/src/pybmds/utils.py index 12755ffb..55eaccc5 100644 --- a/src/pybmds/utils.py +++ b/src/pybmds/utils.py @@ -78,3 +78,7 @@ def get_version(): def camel_to_title(txt: str) -> str: return re.sub(r"(?<=\w)([A-Z])", r" \1", txt) + + +def unique_items(settings: list, getter: str) -> str: + return ", ".join(sorted(list(set(str(getattr(setting, getter)) for setting in settings)))) diff --git a/tests/test_pybmds/test_utils.py b/tests/test_pybmds/test_utils.py index 6925c6e4..6db323ec 100644 --- a/tests/test_pybmds/test_utils.py +++ b/tests/test_pybmds/test_utils.py @@ -1,5 +1,7 @@ +from dataclasses import dataclass + import pybmds -from pybmds.utils import get_version +from pybmds.utils import get_version, unique_items def test_citation(): @@ -9,3 +11,12 @@ def test_citation(): def test_get_version(): version = get_version() assert int(version.dll.split(".")[0]) >= 24 # assume dll in format "YY.MM..." + + +@dataclass +class Foo: + bar: str + + +def test_unique_items(): + assert unique_items([Foo(bar="b"), Foo(bar="a"), Foo(bar="b")], "bar") == "a, b"