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
16 changes: 4 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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.
Expand Down
Binary file modified demo_files/output.pdf
Binary file not shown.
15 changes: 9 additions & 6 deletions demo_files/output.txt
Original file line number Diff line number Diff line change
@@ -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
18 changes: 14 additions & 4 deletions gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/ecoff_fitter/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions src/ecoff_fitter/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 27 additions & 3 deletions src/ecoff_fitter/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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}")


36 changes: 36 additions & 0 deletions tests/test_ecoff_fitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

68 changes: 62 additions & 6 deletions tests/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand All @@ -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)

Loading