diff --git a/README.md b/README.md index 548bf7a..02fcc36 100644 --- a/README.md +++ b/README.md @@ -23,15 +23,9 @@ Demo input files are provided in `demo_files/` to illustrate basic use. ## 🛠 Installation -### Install from PyPI - -```bash -pip install ecoffitter -``` - ### Install from source -Assuming in project directory: +Assuming in project directory (after git cloning): ```bash pip install -e . @@ -127,8 +121,6 @@ If `--outfile` ends in `.txt`, the tool writes: - ECOFF estimate - fitted mean & variance (per component) - mixture weights (if multiple distributions) -- percentiles -- likelihood values ### 2. PDF Report @@ -137,8 +129,8 @@ If `--outfile` ends in `.pdf`, the tool writes: - histogram of observed MICs - fitted distribution curve(s) - ECOFF location marker -- table of model parameters -- censoring diagnostics +- fitted mean & variance (per component) +- mixture weights (if multiple distributions) ## 🚀 Command-Line Usage @@ -147,7 +139,7 @@ Once installed, you can call the CLI. Example using demo files: ```bash -ecoff-fitter --input demo_files/input.txt --params demo_files/params.txt --outfile demo_files/output.txt +ecoff-fitter --input demo_files/censored.txt --params demo_files/params.txt --outfile demo_files/output.txt ``` Instead of using a parameter file, you can also specify parameters directly. diff --git a/demo_files/output.pdf b/demo_files/output.pdf index 4ba591a..acee096 100644 Binary files a/demo_files/output.pdf and b/demo_files/output.pdf differ diff --git a/demo_files/output.txt b/demo_files/output.txt index 837b7ad..d0b1ca3 100644 --- a/demo_files/output.txt +++ b/demo_files/output.txt @@ -1,6 +1,9 @@ -ECOFF: 0.29 -99th percentile: 0.29 -97.5th percentile: 0.22 -95th percentile: 0.17 -μ₁: 0.049303392476164935, σ₁: 2.1439340245815863 -μ₂: 14.283983662174911, σ₂: 1.354070842559145 + ECOFF: 0.3963 + log scale: -1.3354 + + Component 1: + μ = 0.0835 + σ (folds) = 1.9534 + Component 2: + μ = 3.8329 + σ (folds) = 1.4984 diff --git a/gui.py b/gui.py index 07b2547..92791a5 100644 --- a/gui.py +++ b/gui.py @@ -55,6 +55,16 @@ def __init__(self, root): self.percentile_entry.insert(0, "99") self.percentile_entry.pack(side="left", padx=10) + self.verbose_var = tk.BooleanVar(value=False) + verbose_frame = tk.Frame(root) + verbose_frame.pack(pady=5, anchor="w") + tk.Checkbutton( + verbose_frame, + text="model diagnostics", + variable=self.verbose_var, + ).pack(side="left") + + # OUTPUT FILE output_frame = tk.Frame(root) output_frame.pack(pady=5, anchor="w") @@ -125,6 +135,7 @@ def run_ecoff(self): tails = int(self.tails_entry.get()) if self.tails_entry.get().strip() else None percentile = float(self.percentile_entry.get()) outfile = self.output_entry.get() + verbose = self.verbose_var.get() if not input_file: messagebox.showerror("Error", "You must select an input MIC data file.") @@ -163,16 +174,15 @@ def run_ecoff(self): text = "ECOFF RESULTS\n=====================================\n\n" global_report = GenerateReport.from_fitter(global_fitter, global_result) + if len(individual_results) > 1: - - text += global_report.to_text("GLOBAL FIT") + text += global_report.to_text("GLOBAL FIT", verbose=verbose) text += "\nINDIVIDUAL FITS:\n-------------------------------------\n" - # Individual fits for name, (fitter, result) in individual_results.items(): rep = GenerateReport.from_fitter(fitter, result) - text += rep.to_text(label=name) + text += rep.to_text(label=name, verbose=verbose) if outfile: diff --git a/src/ecoff_fitter/cli.py b/src/ecoff_fitter/cli.py index 11dd165..ef7cd7a 100644 --- a/src/ecoff_fitter/cli.py +++ b/src/ecoff_fitter/cli.py @@ -117,13 +117,13 @@ def main(argv: Optional[List[str]] = None) -> None: global_report = GenerateReport.from_fitter(global_fitter, global_result) if len(individual_results) > 1: - text += global_report.to_text("GLOBAL FIT") + text += global_report.to_text("GLOBAL FIT", verbose=args.verbose) text += "\nINDIVIDUAL FITS:\n-------------------------------------\n" # Individual fits for name, (fitter, result) in individual_results.items(): rep = GenerateReport.from_fitter(fitter, result) - text += rep.to_text(label=name) + text += rep.to_text(label=name, verbose=args.verbose) if args.outfile: validate_output_path(args.outfile) diff --git a/src/ecoff_fitter/core.py b/src/ecoff_fitter/core.py index 0b30c3f..33406db 100644 --- a/src/ecoff_fitter/core.py +++ b/src/ecoff_fitter/core.py @@ -326,3 +326,29 @@ def compute_ecoff(self, percentile: float) -> Tuple[Any, ...]: mus_sigmas.extend([mus[k], sigmas[k]]) return (ecoff, z_percentile, *mus_sigmas) + + def model_summary(self) -> dict[str, Any]: + K = self.distributions + n = self.obj_df.observations.sum() + wt_idx = int(np.argmin(self.mus_)) + + summary = { + "model_family": "interval-censored log-normal", + "model_type": "mixture" if K > 1 else "single", + "components": K, + "wild_type_component": wt_idx + 1, + "dilution_factor": self.dilution_factor, + "boundary_support": self.boundary_support, + "n_observations": n, + "log_likelihood": self.loglike_, + "converged": self.converged_, + "n_iter": self.n_iter_, + "pis": self.pis_, + } + + # Information criteria (if identifiable) + k_params = 2 * K + (K - 1) + summary["aic"] = 2 * k_params - 2 * self.loglike_ + summary["bic"] = np.log(n) * k_params - 2 * self.loglike_ + + return summary diff --git a/src/ecoff_fitter/report.py b/src/ecoff_fitter/report.py index 08515b4..e24bc75 100644 --- a/src/ecoff_fitter/report.py +++ b/src/ecoff_fitter/report.py @@ -108,10 +108,10 @@ def to_text(self, label: str | None = None, verbose: bool = False) -> str: lines.append(sigma_line) # Verbose model details - if verbose and self.model is not None: + if verbose: lines.append("") lines.append("--- Model details ---") - lines.append(str(self.model)) + lines.extend(self._format_model_summary()) return "\n".join(lines) + "\n" @@ -175,6 +175,28 @@ def _make_pdf(self, title: Optional[str] = None) -> Figure: fig.tight_layout(rect=(0, 0, 1, 0.95)) return fig + + def _format_model_summary(self) -> list[str]: + """ + Format a structured model summary from the fitter, if available. + """ + if not hasattr(self.fitter, "model_summary"): + return [" (model summary unavailable)"] + + summary = self.fitter.model_summary() + lines: list[str] = [] + + for key, value in summary.items(): + if value is None: + continue + + # Format arrays nicely + if isinstance(value, np.ndarray): + value = ", ".join(f"{v:.4f}" for v in value) + + lines.append(f" {key.replace('_', ' ')}: {value}") + + return lines class CombinedReport: @@ -212,13 +234,13 @@ def write_out(self) -> None: lines.append(f"\n===== INDIVIDUAL FIT: {name} =====") lines.append(report.to_text(label=name)) - # Join and write file text = "\n".join(lines) with open(self.outfile, "w") as f: f.write(text) print(f"\nCombined text report saved to: {self.outfile}") + def save_pdf(self) -> None: from matplotlib.backends.backend_pdf import PdfPages @@ -239,3 +261,5 @@ def save_pdf(self) -> None: print(f"Combined PDF saved to {self.outfile}") print(f"Combined PDF saved to {self.outfile}") + + diff --git a/tests/test_ecoff_fitter.py b/tests/test_ecoff_fitter.py index 192dc44..dee18e7 100644 --- a/tests/test_ecoff_fitter.py +++ b/tests/test_ecoff_fitter.py @@ -236,3 +236,39 @@ def fit(self, **kwargs): assert fitter.converged_ assert len(fitter.mus_) == 2 assert np.isfinite(fitter.mus_[0]) + +def test_model_summary_basic(simple_data): + """ + model_summary() should return a structured, self-consistent summary + after fitting. + """ + fitter = ECOFFitter(simple_data, distributions=2) + fitter.fit() + + summary = fitter.model_summary() + + # basic structure + assert isinstance(summary, dict) + + # required keys + required_keys = { + "model_family", + "model_type", + "components", + "wild_type_component", + "n_observations", + "log_likelihood", + "converged", + "pis", + "aic", + "bic", + } + assert required_keys.issubset(summary.keys()) + + # internal consistency + assert summary["components"] == fitter.distributions + assert summary["n_observations"] == fitter.obj_df.observations.sum() + assert np.isclose(np.sum(summary["pis"]), 1.0) + assert summary["converged"] is True + assert summary["bic"] >= summary["aic"] + diff --git a/tests/test_report.py b/tests/test_report.py index 0d8714c..007037f 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -2,7 +2,7 @@ import sys import pytest from unittest.mock import MagicMock, patch, call, ANY - +import numpy as np import ecoff_fitter.cli as cli import ecoff_fitter.report as report @@ -205,15 +205,29 @@ def test_generate_report_to_text_two_dist_verbose(): fitter.dilution_factor = 2 fitter.mus_ = [1, 2] fitter.sigmas_ = [0.2, 0.5] - fitter.model_ = "FAKE_MODEL_DETAILS" - r = cli.GenerateReport(fitter=fitter, ecoff=4.0, z=(10, 8, 6)) + fitter.model_summary.return_value = { + "model_family": "interval-censored log-normal", + "model_type": "mixture", + "components": 2, + "wild_type_component": 1, + "n_observations": 100, + "log_likelihood": -123.45, + "converged": True, + "pis": [0.7, 0.3], + "aic": 260.9, + "bic": 270.1, + } + + r = report.GenerateReport(fitter=fitter, ecoff=4.0, z=(10, 8, 6)) text = r.to_text(verbose=True) assert "Component 1" in text assert "Component 2" in text assert "--- Model details ---" in text - assert "FAKE_MODEL_DETAILS" in text + assert "model family: interval-censored log-normal" in text + assert "components: 2" in text + def test_combined_report_write_out(tmp_path): # CombinedReport now writes to its outfile @@ -235,8 +249,7 @@ def test_combined_report_write_out(tmp_path): individual_reports={"A": report_A, "B": report_B}, ) - # Act - combined.write_out() # NO ARGUMENT NOW + combined.write_out() text = outfile.read_text() @@ -253,3 +266,46 @@ def test_combined_report_write_out(tmp_path): report_A.to_text.assert_called_with(label="A") report_B.to_text.assert_called_with(label="B") + +def test_generate_report_format_model_summary(): + """ + _format_model_summary() should format key/value pairs from + fitter.model_summary() into readable text lines. + """ + fitter = MagicMock() + fitter.model_summary.return_value = { + "model_family": "interval-censored log-normal", + "model_type": "mixture", + "components": 2, + "wild_type_component": 1, + "n_observations": 100, + "log_likelihood": -123.45, + "converged": True, + "pis": np.array([0.7, 0.3]), + "aic": 260.9, + "bic": 270.1, + } + + r = report.GenerateReport( + fitter=fitter, + ecoff=4.0, + z=(10, 8, 6), + ) + + lines = r._format_model_summary() + + # basic structure + assert isinstance(lines, list) + assert all(isinstance(line, str) for line in lines) + + # content checks (formatted, not raw) + assert any("model family: interval-censored log-normal" in line for line in lines) + assert any("model type: mixture" in line for line in lines) + assert any("components: 2" in line for line in lines) + assert any("wild type component: 1" in line for line in lines) + assert any("log likelihood: -123.45" in line for line in lines) + assert any("converged: True" in line for line in lines) + assert any("pis: 0.7000, 0.3000" in line for line in lines) + assert any("aic: 260.9" in line for line in lines) + assert any("bic: 270.1" in line for line in lines) +