From e865151108b63e19a5823c106f6dc36399a3bca3 Mon Sep 17 00:00:00 2001 From: tjkessler Date: Tue, 21 Jul 2026 20:19:33 -0400 Subject: [PATCH 1/3] Add approved design doc for API-stable 4.1.5 modernization. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document Phases A–E (contract suite, packaging/CI, deps/internals, Sphinx docs, release) with a frozen public API and 90% coverage target. Co-authored-by: Cursor --- docs/design/DESIGN.md | 525 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 docs/design/DESIGN.md diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md new file mode 100644 index 0000000..ec0cdf1 --- /dev/null +++ b/docs/design/DESIGN.md @@ -0,0 +1,525 @@ +# ECNet: API-stable modernization of QSPR-based fuel property prediction + +**Design Document — v0.1** +**Status:** Approved (2026-07-21) +**Package:** `ecnet` (PyPI / import name unchanged) +**License:** MIT (unchanged) +**Current release baseline:** `4.1.4` (2024-08-29) +**First modernization tag:** `4.1.5` (after Phases A and B) + +--- + +## 1. Summary + +ECNet is an open-source Python package for building multilayer-perceptron models that predict fuel properties from molecular structure using quantitative structure–property relationship (QSPR) descriptors. It ships bundled property datasets (cetane number, yield sooting index, research/motor octane number, and related targets), integrates PaDEL and alvaDesc descriptor backends, provides hyperparameter-tuning helpers, and includes analytical blend-property equations. + +The package already has a JOSS paper and a published PyPI history, but repository engineering has lagged: thin tests, pinned vulnerable dependencies, deprecated version introspection, sparse governance files, and documentation that partially describes an older architecture. + +The present work modernizes packaging, testing, CI, documentation, governance, dependency hygiene, and selected internals **without changing the public API contract**. Downstream code that imports the current callables and classes must continue to work with identical signatures, default behaviors, return shapes, and primary exception types within the `4.1.x` / `4.2.x` compatibility series. + +This document is the design source of truth for a five-phase modernization program (Phases A–E). + +--- + +## 2. Motivation and problem statement + +### 2.1 Current landscape + +Fuel property prediction from molecular descriptors remains a practical need in combustion and biofuel screening. ECNet provides a PyTorch-based regression path over QSPR inputs plus domain-specific blend mixing rules. Empirically, the package is installable and its existing seventeen tests pass under a constrained environment, but several engineering gaps limit safe maintenance: + +| Area | Current state (baseline `4.1.4`) | +|------|----------------------------------| +| Public API | `ECNet`, dataset loaders/classes, tasks, blends, callbacks — stable in practice | +| Layout | Flat `ecnet/` package (not `src/`); ~1.8k lines of Python | +| Tests | Single `tests/test_all.py` (17 tests); ~66% line coverage; **`ecnet.blends` at 0%**; `Validator` test is a no-op | +| Versioning | `pkg_resources` in `ecnet/__init__.py` (broken on setuptools ≥82; deprecated) | +| Dependencies | Exact pins (`torch==2.4.0`, `scikit-learn==1.5.1`, …); multiple known torch advisories | +| Tooling | No `[dev]`/`[docs]` extras; no ruff/pre-commit; no coverage floor | +| CI | Single Python 3.11 job on push; outdated Actions majors; no coverage gate; no recorded runs observed | +| Docs | MkDocs + mkdocstrings stubs; JOSS `paper.md` (2017) describes a project/build/node architecture not present in v4 | +| Governance | No CONTRIBUTING, CHANGELOG, CITATION.cff, SECURITY, CODE_OF_CONDUCT, issue/PR templates | +| Hygiene | No standard Python `.gitignore` content historically; tests write temp files into the working tree; `.DS_Store` tracked under package data | +| Data | Bundled `.smiles`/`.target` property sets in-package; large `databases/` CSVs (~64 MB) in the git tree | +| Publishing | Long-lived `PYPI_API_TOKEN` in release workflow | + +### 2.2 Why now + +1. Downstream users and notebooks rely on import stability; modernization debt raises the cost of every dependency or bugfix. +2. Version introspection already fails under current setuptools; that is a hard install-time regression for some environments. +3. Exact torch pins block security updates and complicate fresh installs as wheels age. +4. Coverage gaps (especially blends and public loaders) make internal refactors and dependency bumps unsafe. +5. A compatibility-preserving modernization is cheaper than an API rewrite that would strand existing example notebooks and papers citing the current API. + +### 2.3 Non-goals + +The following are explicitly out of scope for Phases A–E: + +1. **Changing the public API** — no renamed exports, no required new arguments, no change to return container types or primary exception types for documented failure modes. +2. **Replacing the default QSPR backend** — PaDEL remains the default (`backend='padel'`); alvaDesc remains optional via existing paths. +3. **Reintroducing the pre-v4 project/build/node ensemble architecture** as the primary API (historical JOSS description may be archived or clarified, not resurrected as the default surface). +4. **Mandatory GUI** or web service. +5. **Hard dependency on RDKit or Mordred** in the default install (optional additive backends may be sketched in Phase E only if they do not alter existing defaults). +6. **JOSS resubmission** as a required deliverable of Phases A–E (citation metadata and docs alignment are in scope; a new paper is not). +7. **Changing bundled property dataset contents** without an explicit, versioned data revision and migration note. + +--- + +## 3. What's genuinely new + +This program is an engineering modernization of an existing scientific package, not a new property-prediction algorithm. Differentiation relative to a rewrite is: + +1. **Frozen-contract modernization** — tooling, tests, CI, docs, and internals improve while `import ecnet` and documented subpackage exports remain drop-in compatible. +2. **Characterization tests as release gates** — signature locks and numeric oracles (especially blend equations and seeded training smoke tests) must stay green across dependency and internal changes. +3. **Dependency ranges with a CI matrix** — move from brittle exact pins to supported ranges while proving behavior on multiple Python versions. +4. **Production packaging baseline** — modern extras, coverage floor, multi-version CI, Sphinx + Furo, governance files, and trusted PyPI publishing. +5. **Explicit stability policy** — additive optional kwargs and clearer errors are allowed; behavior changes that alter predictions or descriptor schemas require a documented version bump strategy. + +--- + +## 4. Goals + +Numbered goals are testable exit criteria for the modernization program. + +1. **G1 — API contract suite.** CI fails if public signatures change or if blend/dataset/model characterization oracles regress beyond documented tolerances. +2. **G2 — Version and packaging.** `ecnet.__version__` resolves via `importlib.metadata`; `[dev]` and `[docs]` extras install; `python -m build` succeeds; classifiers list supported Python versions. +3. **G3 — Tooling and quality gates.** ruff lint/format, pytest-cov with a documented coverage floor, and pre-commit are enforced in CI. +4. **G4 — CI matrix.** Push/PR CI runs lint + tests on Python 3.11–3.12 (3.13 when torch wheels allow); coverage reported; Actions kept current. +5. **G5 — Test depth.** `ecnet.blends` ≥95% line coverage; overall package line coverage **≥90%**; no stub tests; public `load_*` loaders and `Validator` exercised; tests use isolated temp paths. +6. **G6 — Dependency hygiene.** Compatible version ranges replace exact pins where safe; `pip-audit` clean or documented exceptions; Dependabot (or equivalent) enabled. +7. **G7 — Documentation.** User-facing docs describe the **current** v4 API; install + quickstart + API reference build with warnings as errors; JOSS historical architecture clearly labeled as prior generation. +8. **G8 — Governance.** CONTRIBUTING, CHANGELOG, CITATION.cff, SECURITY, CODE_OF_CONDUCT, and issue/PR templates present. +9. **G9 — Release path.** Trusted publishing (OIDC) replaces long-lived tokens; a compatibility release ships only after gates for that milestone pass. +10. **G10 — Data provenance.** Bundled property datasets have dataset cards (scope, units, provenance, license notes); top-level `databases/` stays out of wheels and is documented as a non-install research archive. + +--- + +## 5. Target users and use cases + +| User | Use case | +|------|----------| +| Combustion / biofuel researcher | Train or evaluate QSPR models for CN, YSI, RON/MON, and related properties | +| Blend analyst | Combine component property predictions with analytical blend equations | +| Pipeline author | Embed `ECNet` and dataset loaders in an existing PyTorch workflow | +| Library integrator | Depend on `ecnet` from PyPI without adapting to API churn | +| Maintainer / contributor | Run lint/tests locally and in CI; cut safe patch/minor releases | + +**Primary constraint:** integrators and existing notebooks must not need code changes when upgrading within the compatibility series. + +--- + +## 6. Related work + +| Project | What it does | Status | Gap this package fills | +|---------|--------------|--------|------------------------| +| [DeepChem](https://deepchem.io/) | Broad cheminformatics / ML toolkit | Active | General-purpose; not fuel-property-focused with bundled combustion datasets and blend equations | +| [Chemprop](https://chemprop.readthedocs.io/) | Message-passing NNs for molecular property prediction | Active | Different model class (graph MPNN); not QSPR-descriptor MLP + fuel blend helpers | +| [RDKit](https://www.rdkit.org/) + scikit-learn / PyTorch | Descriptor/fingerprint generation + custom models | Ecosystem standard | Requires assembling datasets, blend rules, and training loops; ECNet packages a fuel-oriented path | +| [Mordred](https://doi.org/10.1186/s13321-018-0258-y) | Descriptor calculator | Active alternative engine | Complementary; not a fuel modeling package | +| [PaDEL-Descriptor](https://doi.org/10.1002/jcc.21707) / [PaDELPy](https://github.com/ecrl/padelpy) | Descriptor engine and Python wrapper | Mature | Upstream descriptor generation; ECNet consumes descriptors for prediction | + +**Primary literature to cite where methods are discussed (docstrings, Sphinx, tests, `paper.bib` as applicable):** + +- Yap CW. PaDEL-Descriptor. *J Comput Chem.* 2011 — descriptor backend. +- Blend mixing rules as already cited in `ecnet/blends/predict.py` (NREL CN blending; Semwal et al. for cloud point; Ding et al. for kinematic viscosity; LHV and YSI DOIs in module docstrings). +- Prior ECNet JOSS paper and fuel-property ANN literature already listed in `paper/paper.bib` — retain for historical citation; do not treat the 2017 architecture description as the current API. + +--- + +## 7. Architecture overview + +ECNet remains a **layered scientific ML package**. Modernization may refine helpers and packaging layout but must preserve the import surface. + +```text ++------------------------------------------------------------------+ +| Public API | +| ecnet.ECNet, ecnet.model.load_model, ecnet.__version__ | +| ecnet.datasets (load_*, QSPRDataset*) | +| ecnet.tasks (select_rfr, tune_*) | +| ecnet.blends (property blend predictors + selected errors) | +| ecnet.callbacks (LRDecayLinear, Validator, ...) | ++--------------------------------+---------------------------------+ + | + +-----------------------+-----------------------+ + v v v ++----------------+ +------------------+ +------------------+ +| L3 Application | | L2 Domain | | L2 Domain | +| tasks/ |---->| model + | | blends/ | +| feature sel. | | callbacks | | equations | +| param tuning | +--------+---------+ +------------------+ ++--------+-------+ | + | v + | +------------------+ + +------------>| L1 Datasets | + | structs, loaders | + | QSPR backends | + +--------+---------+ + v + +------------------+ + | L0 Data + deps | + | bundled SMILES/ | + | targets; torch; | + | sklearn; padel; | + | alvadesc; ecabc | + +------------------+ +``` +**Dependency rule:** Higher layers may import lower layers; L1 must not import L3; `blends` must remain free of torch training code (pure numeric helpers). New helpers stay private (`_`-prefixed) unless deliberately re-exported. + +**Layout decision (approved):** Migrate to `src/ecnet/` in **Phase B**. The import name remains `import ecnet`. Package data (`.smiles` / `.target`) must continue to ship in sdists and wheels. + +--- + +## 8. Core data model and public API + +### 8.1 Frozen public surface + +```python +from ecnet import ECNet, __version__ +from ecnet.model import load_model + +from ecnet.datasets import ( + load_bp, load_cn, load_cp, load_kv, load_lhv, + load_mon, load_mp, load_pp, load_ron, load_ysi, + QSPRDataset, QSPRDatasetFromFile, QSPRDatasetFromValues, +) + +from ecnet.tasks import ( + select_rfr, tune_batch_size, tune_model_architecture, tune_training_parameters, +) + +from ecnet.blends import ( + cetane_number, yield_sooting_index, kinematic_viscosity, + cloud_point, lower_heating_value, + linear_blend_err, exponential_blend_err, kv_error, +) + +from ecnet.callbacks import LRDecayLinear, Validator, Callback, CallbackOperator +``` + +`PCADataset` exists in source today but is **not** re-exported from `ecnet.datasets`. **Approved:** keep the advanced import `ecnet.datasets.structs.PCADataset` unless examples are updated to require a public re-export; only then add it to `ecnet.datasets.__init__` in Phase B (additive). Characterization tests may cover it via the structs import without changing `__init__` exports by default. + +### 8.2 Signature contracts (preserve) + +```python +class ECNet(nn.Module): + def __init__( + self, + input_dim: int, + output_dim: int, + hidden_dim: int, + n_hidden: int, + dropout: float = 0.0, + device: str = "cpu", + ): ... + + def fit( + self, + smiles: list[str] | None = None, + target_vals: list[list[float]] | None = None, + dataset: QSPRDataset | None = None, + backend: str = "padel", + batch_size: int = 32, + epochs: int = 100, + lr_decay: float = 0.0, + valid_size: float = 0.0, + valid_eval_iter: int = 1, + patience: int = 16, + verbose: int = 0, + random_state: int | None = None, + shuffle: bool = False, + **kwargs, # Adam optimizer kwargs + ) -> tuple[list[float], list[float]]: ... + + def forward(self, x: torch.Tensor) -> torch.Tensor: ... + def save(self, model_filename: str) -> None: ... + +def load_model(model_filename: str) -> ECNet: ... + +def load_(as_dataset: bool = False, backend: str = "padel"): ... +# prop ∈ {bp, cn, cp, kv, lhv, mon, mp, pp, ron, ysi} + +def select_rfr(dataset: QSPRDataset, total_importance: float = 0.95, ...): ... +def tune_batch_size(n_bees: int, n_iter: int, dataset_train, dataset_eval, n_processes, ...): ... +def tune_model_architecture(...): ... +def tune_training_parameters(...): ... + +def cetane_number(values: list[float], vol_fractions: list[float]) -> float: ... +def cloud_point(values: list[float], vol_fractions: list[float]) -> float: ... # °C in / °C out +def kinematic_viscosity(values: list[float], vol_fractions: list[float]) -> float: ... # cSt +def lower_heating_value(values: list[float], vol_fractions: list[float]) -> float: ... +def yield_sooting_index(values: list[float], vol_fractions: list[float]) -> float: ... +``` + +Additive optional keyword arguments are allowed if defaults preserve today’s behavior. + +### 8.3 Units and numeric conventions + +| Quantity | Canonical unit in public API | Notes | +|----------|------------------------------|-------| +| Cloud point blend I/O | °C | Internal Rankine conversion must remain consistent | +| Kinematic viscosity | cSt | Per Ding et al. mixing rule already implemented | +| Cetane number, YSI, LHV, octane numbers | Dimensionless property scales as in bundled targets | Document in dataset cards | +| Model targets | As supplied by user / bundled `.target` files | No silent unit conversion in `ECNet.fit` | + +Numerical assertions use `pytest.approx` with tolerances justified per test (blends: tight absolute/relative tolerances on algebraic results; training smoke tests: finite losses and monotonic-enough decrease under fixed seeds — not literature accuracy claims). + +### 8.4 Stability policy + +1. **Patch (`4.1.x`):** bugfixes, packaging, tests, docs corrections; oracles must match. +2. **Minor (`4.2.0`):** internal hardening, Sphinx/governance completion, dependency range expansions proven on CI; still API-compatible. +3. **Major (`5.0.0`):** only with explicit approval — e.g. removing pickle-based full-module `torch.load`, changing default descriptor backends, or altering bundled dataset schemas. + +--- + +## 9. Module design + +### 9.1 `ecnet` (`__init__.py`) + +**Purpose:** Export `ECNet` and `__version__`. +**Changes:** Replace `pkg_resources` with `importlib.metadata.version("ecnet")`; add explicit `__all__`. + +### 9.2 `ecnet.model` + +**Purpose:** `ECNet` MLP, training loop, save/load. +**Preserve:** Constructor args; `fit` defaults; MSE loss; ReLU between layers; validation/early-stopping semantics when `valid_size > 0`. +**Internal improvements (Phase C):** `load_model` shim that accepts legacy full-module `.pt` pickles and a newer state-dict format, without changing the `load_model(path)` signature; avoid CWD pollution; keep `save` requiring `.pt` extension (prefer writing the new format going forward while remaining able to read legacy files). + +### 9.3 `ecnet.datasets` + +**Purpose:** QSPR dataset types, property loaders, PaDEL/alvaDesc utilities. +**Preserve:** Loader names and `(smiles, targets)` vs `QSPRDataset` return modes; default `backend='padel'`. +**Improvements:** Dataset cards (Phase D/G10); tests for all `load_*`; document `PCADataset` export policy. + +### 9.4 `ecnet.tasks` + +**Purpose:** Random-forest feature selection and ABC-based hyperparameter tuning (`ecabc`). +**Preserve:** Function names and return dict key structures used by callers/tests. +**Tests:** Keep short-iteration tuning tests; mark slow variants if expanded. + +### 9.5 `ecnet.blends` + +**Purpose:** Analytical blend property predictors and error propagation helpers. +**Preserve:** Equations and units. +**Priority:** Highest test value in Phase A (pure functions, currently 0% coverage). + +### 9.6 `ecnet.callbacks` + +**Purpose:** Training callbacks (`LRDecayLinear`, `Validator`, operator plumbing). +**Preserve:** Callback method contracts used by `ECNet.fit`. +**Tests:** Replace the no-op `Validator` test with a real early-stopping characterization test. + +### 9.7 `databases/` (repo root) + +**Purpose today:** Large CSV masters + filter script; not part of the installed wheel. +**Approved:** keep out of wheels and sdists as a non-install research archive; add a README/dataset card clarifying that role (Phase D / G10). Do not silently bundle into PyPI artifacts. + +--- + +## 10. Dependencies and ecosystem integration + +| Kind | Decision | +|------|----------| +| Runtime | `torch`, `scikit-learn`, `padelpy`, `alvadescpy`, `ecabc` — retain; convert exact `==` pins to compatible ranges after characterization suite exists | +| System / external | alvaDesc license for `backend='alvadesc'`; Java for PaDEL via padelpy | +| Optional extras | `[dev]` — pytest, pytest-cov, ruff, pre-commit, build, pip-audit; `[docs]` — Sphinx + Furo (+ napoleon, autodoc, myst as needed) | +| Future optional extras (Phase E sketch only) | `[rdkit]` / fingerprint backends — additive; default backend unchanged | +| Bundled artifacts | `.smiles` / `.target` property files via package data | + +No new hard runtime dependencies in Phases A–D without a separate maintainer decision. + +--- + +## 11. Validation strategy + +### 11.1 Phase A — Contract suite (load-bearing) + +| Test class | What | Pass criteria | +|------------|------|---------------| +| Signature locks | `inspect.signature` on public callables/classes | Parameter names + defaults match frozen contract | +| Blend oracles | Fixed component values + volume fractions for CN, CP, KV, LHV, YSI | Algebraic results within tight `pytest.approx` tolerances; cite equation sources in test comments | +| Blend error helpers | `linear_blend_err`, `exponential_blend_err`, `kv_error` | Numeric checks vs hand-computed fixtures | +| Dataset loaders | Each `load_*` | Equal smiles/target lengths; types; optional `as_dataset=True` smoke | +| Dataset structs | `QSPRDataset`, `FromFile`, `FromValues` | Existing descriptor-count checks retained (`1875` for default PaDEL path) | +| Callbacks | `LRDecayLinear`, `Validator` | Decay stops at expected epoch; validator triggers patience behavior on synthetic loaders | +| Model | construct / short `fit` / save-load | Seeded finite losses; round-trip prediction equality for saved weights under documented load policy | +| Tasks | `select_rfr`, tune helpers with `n_iter=1` | Existing structural assertions retained | + +Store numeric fixtures under `tests/fixtures/` with a short README (engine/self-consistency vs literature oracles clearly labeled). + +### 11.2 Coverage targets + +| Scope | After Phase A | After Phase B CI gate | After Phase C | +|-------|---------------|----------------------|---------------| +| `ecnet.blends` | ≥95% | ≥95% | ≥95% | +| Package overall (`ecnet/**`) | Path to **90%** (raise aggressively) | **≥90%** fail-under | **≥90%** maintained | +| Stub tests | Forbidden | Forbidden | Forbidden | + +**Approved coverage aim:** overall package line coverage **90%** (blends ≥95%). Phase A builds the suite needed to hit 90%; Phase B enforces fail-under in CI. + +### 11.3 Integration / notebooks + +- Keep descriptor-backed tests that invoke PaDEL as integration tests (may be slower); default CI must remain reliable on Ubuntu. +- Existing `examples/*.ipynb` are refreshed in Phase D; optional `nbmake` smoke in CI once notebooks are deterministic enough. + +### 11.4 CI gates + +Lint (ruff) → tests + coverage floor → docs build (once Sphinx exists in Phase D). Release workflow builds artifacts and publishes via OIDC. + +--- + +## 12. Open-source packaging + +| Concern | Decision | +|---------|----------| +| Build backend | setuptools via `pyproject.toml` (current) | +| Layout | `src/ecnet/` in Phase B | +| Python versions | `requires-python = ">=3.11"`; CI on 3.11 and 3.12; add 3.13 when torch supports it in-range | +| License | MIT | +| Docs | Migrate MkDocs → Sphinx + Furo under `docs/source/` in Phase D; update `.readthedocs.yaml` | +| CI | `.github/workflows/ci.yml` — lint + test matrix on push/PR | +| Release | Tag/release-triggered publish with PyPI trusted publishing | +| Governance | CONTRIBUTING, CODE_OF_CONDUCT, CHANGELOG, SECURITY, issue/PR templates | +| Citation | `CITATION.cff` for the software; retain JOSS citation guidance in README | +| Supply chain | Current Actions; Dependabot; no secrets in repo; drop long-lived PyPI token | +| FAIR data | Dataset cards for bundled property sets (`fair_data` profile intent) | + +--- + +## 13. Roadmap (Phases A–E) + +| Phase | Theme | Primary goals | Suggested version | +|-------|-------|---------------|-------------------| +| **A** | Freeze the contract | G1, G5 path — signature locks, blend oracles, loader/Validator tests; blends ≥95%; overall on path to **90%** | Commits on `4.1.4` tip (no tag yet) | +| **B** | Modernize the shell | G2, G3, G4 — `importlib.metadata`, extras, `src/` layout, ruff/pre-commit, CI matrix, **90%** coverage gate | Tag **`4.1.5`** after A+B | +| **C** | Tests, deps, internals | G5, G6 — maintain ≥90%; torch/sklearn ranges; legacy+state-dict `load_model` shim; temp-path hygiene | `4.1.6` or fold into `4.2.0` | +| **D** | Docs and governance | G7, G8, G10 — Sphinx + Furo, dataset cards, JOSS history note, governance files | With **`4.2.0`** | +| **E** | Compatibility release series | G9 — trusted publishing; changelog discipline; optional additive extras only | `4.1.5` first; `4.2.0` after C–D | + +```mermaid +flowchart LR + A[Phase A Contract] --> B[Phase B Packaging/CI] + B --> C[Phase C Tests/Deps/Internals] + C --> D[Phase D Docs/Governance] + D --> E[Phase E Release] + A -.->|oracles gate every later phase| C + A -.->|oracles gate release| E +``` + +### 13.1 Phase A — Freeze the contract + +**Intent:** Make unsafe refactors and dependency bumps detectable. + +Deliverables: + +1. Split/expand `tests/` to mirror subpackages (`tests/blends/`, `tests/datasets/`, `tests/model/`, …). +2. Blend golden oracles with cited equations. +3. Signature lock tests for the frozen public surface. +4. Real `Validator` test; public `load_*` smoke tests. +5. `docs/API_STABILITY.md` (later folded into Sphinx). +6. Fix `__version__` via `importlib.metadata` immediately if needed to unblock local installs (also listed under B; may land at the start of A). + +**Exit:** `pytest` green; blends ≥95%; overall coverage at or on a clear path to **90%** (prefer meeting 90% before leaving A); no CWD pollution from new tests (`tmp_path`). + +### 13.2 Phase B — Modernize the shell + +**Intent:** Bring packaging and CI to current scientific Python norms without logic rewrites. + +Deliverables: + +1. Migrate to `src/ecnet/`; verify wheel contains package data (not `databases/`). +2. `[dev]` / `[docs]` extras; ruff; pre-commit; coverage fail-under **90%** (blends monitored at ≥95%). +3. Standard Python ignore rules in `.gitignore` (in addition to any local maintainer ignores). +4. Replace/extend workflows: PR+push, Python 3.11–3.12, lint + pytest-cov. +5. Remove tracked `.DS_Store` from package data paths. +6. Re-export `PCADataset` from `ecnet.datasets` **only if** examples require it; otherwise document the advanced import. + +**Exit:** `pip install -e ".[dev]"` works on supported Pythons; CI green with **90%** fail-under; ready to tag `4.1.5`. + +### 13.3 Phase C — Tests, dependencies, and internals + +**Intent:** Deepen confidence and reduce supply-chain risk behind oracles. + +Deliverables: + +1. Maintain overall coverage ≥90%; eliminate stub tests. +2. Relax dependency pins to ranges; expand CI as torch allows. +3. Implement `load_model` shim: read legacy full-module `.pt` files and a newer state-dict format; keep signature unchanged; prefer writing the new format on `save`. +4. Ensure training/tests use isolated temporary directories. +5. Document any intentional behavioral clarifications in CHANGELOG (still API-compatible). + +**Exit:** Oracles unchanged; `pip-audit` acceptable; coverage floors met. + +### 13.4 Phase D — Docs and maintainer surface + +**Intent:** Align documentation with the actual v4 API and make contribution sustainable. + +Deliverables: + +1. Sphinx + Furo site: install, quickstart, API autodoc, units/data pages. +2. README refresh (install, citation, contact, link to current API). +3. Explicit note that the 2017 JOSS architecture description is historical. +4. Dataset cards for bundled properties; decision text for `databases/`. +5. CONTRIBUTING, CHANGELOG, CITATION.cff, SECURITY, CODE_OF_CONDUCT, templates. +6. Optional notebook cleanup + nbmake smoke. + +**Exit:** `sphinx-build -W` passes; governance checklist complete. + +### 13.5 Phase E — Compatibility release series + +**Intent:** Ship modernization to PyPI safely. + +Deliverables: + +1. **`4.1.5`** after A+B: changelog entry, OIDC trusted publishing, tag, PyPI upload, clean-venv smoke install. +2. **`4.2.0`** after C–D: broader dependency ranges, docs/governance complete. +3. Optional additive extras (e.g. fingerprint backends) only if they do not change defaults. +4. Monitor issues; reserve `5.0.0` for intentional breaks. + +**Exit (`4.1.5`):** PyPI artifact installable; Phase A oracles pass on the release commit. + +--- + +## 14. Open questions + +### 14.1 Resolved (2026-07-21) + +| # | Decision | +|---|----------| +| Q1 | Migrate to `src/ecnet/` in **Phase B**. | +| Q2 | Tag **`4.1.5`** after Phases A and B; **`4.2.0`** after C–D. | +| Q3 | Keep `databases/` **out of wheels**; document as non-install research archive (README/dataset card in Phase D). | +| Q4 | Use **Sphinx + Furo** in Phase D (replace MkDocs on Read the Docs). | +| Q5 | Aim for **90%** overall package line coverage (blends ≥95%); enforce 90% fail-under in Phase B CI. | +| Q6 | Export `PCADataset` from `ecnet.datasets.__init__` in Phase B **only if examples need it**; otherwise keep/document advanced import (`ecnet.datasets.structs.PCADataset`). Examples do not currently reference it. | +| Q7 | Keep scholarly prose default; do **not** enable authorial voice for this program. | +| Q8 | Prefer a `load_model` **shim** that loads legacy full-module pickles and a new state-dict format; never change the `load_model(path)` signature. | + +### 14.2 Still open (non-blocking) + +| # | Question | Options | Recommendation | Owner | +|---|----------|---------|----------------|-------| +| Q9 | macOS/Windows CI? | Ubuntu-only vs multi-OS | Ubuntu required for `4.1.5`; expand later if user reports demand | Maintainer | +| Q10 | Should alvaDesc-backed tests run in CI? | Skip without license / optional job | Default CI uses PaDEL only; document alvaDesc as manual/optional | Maintainer | + +--- + +## Appendix A — Mapping audit findings to phases + +| Finding | Phase | +|---------|-------| +| Thin tests; blends 0%; Validator stub; no `load_*` coverage | A | +| Broken `pkg_resources` version import | A (early) / B | +| No extras; no ruff/pre-commit; flat layout; weak CI | B | +| Exact torch pin; advisories; `torch.load` warning; temp CWD files | C | +| MkDocs stubs; JOSS architecture drift; no governance; dataset provenance | D | +| Token-based PyPI publish; unreleased modernization | E | +| RDKit/Mordred/GUI future ideas | Non-goal for A–E (optional additive later) | + +## Appendix B — Approval checklist + +- [x] Maintainer approves API freeze and non-goals (§2.3, §8) +- [x] Maintainer confirms Q1, Q2, Q4, Q5, Q7 (`src/` in B; tag `4.1.5`; Sphinx; **90%** coverage; scholarly prose) +- [x] Maintainer answers Q3, Q6, Q8 (`databases/` out of wheels; `PCADataset` only if examples need it; `load_model` legacy+state-dict shim) +- [ ] Q9–Q10 deferred (Ubuntu CI; PaDEL-only default CI) — non-blocking +- [x] Design accepted (2026-07-21) → next: break Phases A–E into implementation tasks and execute Phase A first +- [ ] No implementation of B–E until Phase A oracles exist (recommended gate) +- [ ] Tag `4.1.5` only when Phases A and B exit criteria pass From 3f2edeb25b22b7329d96d19051cad1c5808e508f Mon Sep 17 00:00:00 2001 From: tjkessler Date: Wed, 22 Jul 2026 14:36:13 -0400 Subject: [PATCH 2/3] Ignore local environments, caches, and agent tooling. Keep clone-local Python artifacts and maintainer-only tooling out of the public tree. Co-authored-by: Cursor --- .gitignore | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc3b2d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Virtual environments +.venv/ +.venv*/ +venv/ + +# Python bytecode / caches +__pycache__/ +*.py[cod] +*$py.class +*.so +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.dmypy.json +.tox/ +.nox/ +.hypothesis/ +.ipynb_checkpoints/ + +# Test / coverage artifacts +.coverage +.coverage.* +coverage.xml +htmlcov/ +.cache/ + +# Build / packaging +dist/ +build/ +*.egg-info/ +*.egg +pip-wheel-metadata/ + +# Docs builds +docs/_build/ +docs/build/ +site/ + +# Secrets and local env (keep example templates trackable) +.env +.env.* +!.env.example +!.env.*.example + +# OS / editor noise +.DS_Store +Thumbs.db +*.swp +*~ + +# Local agent tooling (maintainer-only; not part of published package) +AGENTS.md +CLAUDE.md +.github/copilot-instructions.md +.cursor/ +.claude/ +docs/blueprint/ +docs/MATHEMATICS_VERIFICATION.md From 90714da3e40e31f55fd9f36ac09003125acaba03 Mon Sep 17 00:00:00 2001 From: tjkessler Date: Wed, 22 Jul 2026 17:50:21 -0400 Subject: [PATCH 3/3] Release 4.1.5: API-stable modernization packaging and tooling. Ship src layout, contract tests, Sphinx docs, governance, dependency ranges, OIDC PyPI publishing, and refreshed examples while keeping the public v4 API. Co-authored-by: Cursor --- .github/ISSUE_TEMPLATE/bug_report.yml | 58 ++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 32 + .github/dependabot.yml | 13 + .github/pull_request_template.md | 16 + .github/workflows/ci.yml | 85 +++ .github/workflows/publish_to_pypi.yml | 30 - .github/workflows/release.yml | 38 ++ .github/workflows/run_tests.yml | 26 - .gitignore | 7 + .pre-commit-config.yaml | 18 + .readthedocs.yaml | 20 +- CHANGELOG.md | 57 ++ CITATION.cff | 43 ++ CODE_OF_CONDUCT.md | 129 ++++ CONTRIBUTING.md | 89 +++ README.md | 85 ++- SECURITY.md | 25 + databases/README.md | 24 + databases/filter_property.py | 67 +- docs/API_STABILITY.md | 13 + docs/RELEASING.md | 63 ++ docs/SECURITY_EXCEPTIONS.md | 40 ++ docs/api_blends.md | 26 - docs/api_callbacks.md | 21 - docs/api_datasets.md | 66 -- docs/api_model.md | 4 - docs/api_tasks.md | 21 - docs/design/DESIGN.md | 34 +- docs/index.md | 29 - docs/pip-audit-ignores.txt | 21 + docs/requirements.txt | 2 - docs/source/_static/.gitkeep | 0 docs/source/_templates/.gitkeep | 0 docs/source/api/blends.rst | 10 + docs/source/api/callbacks.rst | 9 + docs/source/api/datasets.rst | 18 + docs/source/api/ecnet.rst | 14 + docs/source/api/index.rst | 15 + docs/source/api/model.rst | 7 + docs/source/api/tasks.rst | 9 + docs/source/conf.py | 103 ++++ docs/source/data.rst | 143 +++++ docs/source/index.rst | 30 + docs/source/installation.rst | 45 ++ docs/source/quickstart.rst | 53 ++ docs/source/stability.rst | 84 +++ docs/source/units.rst | 36 ++ ecnet/__init__.py | 4 - ecnet/blends/__init__.py | 3 - ecnet/datasets/__init__.py | 3 - ecnet/datasets/data/.DS_Store | Bin 6148 -> 0 bytes ecnet/datasets/load_data.py | 287 --------- ecnet/tasks/__init__.py | 3 - ecnet/tasks/feature_selection.py | 40 -- ecnet/tasks/parameter_tuning.py | 303 --------- examples/example.ipynb | 499 ++++++++------- examples/example_multiprop.ipynb | 581 ++++++++++-------- examples/getting_started.ipynb | 481 +++++++-------- mkdocs.yml | 14 - pyproject.toml | 59 +- src/ecnet/__init__.py | 17 + src/ecnet/blends/__init__.py | 19 + {ecnet => src/ecnet}/blends/equations.py | 62 +- {ecnet => src/ecnet}/blends/predict.py | 11 +- {ecnet => src/ecnet}/callbacks.py | 64 +- src/ecnet/datasets/__init__.py | 29 + src/ecnet/datasets/data/README.md | 8 + {ecnet => src/ecnet}/datasets/data/bp.smiles | 2 +- {ecnet => src/ecnet}/datasets/data/bp.target | 2 +- {ecnet => src/ecnet}/datasets/data/cn.smiles | 2 +- {ecnet => src/ecnet}/datasets/data/cn.target | 2 +- {ecnet => src/ecnet}/datasets/data/cp.smiles | 2 +- {ecnet => src/ecnet}/datasets/data/cp.target | 2 +- {ecnet => src/ecnet}/datasets/data/kv.smiles | 2 +- {ecnet => src/ecnet}/datasets/data/kv.target | 2 +- {ecnet => src/ecnet}/datasets/data/lhv.smiles | 2 +- {ecnet => src/ecnet}/datasets/data/lhv.target | 2 +- {ecnet => src/ecnet}/datasets/data/mon.smiles | 2 +- {ecnet => src/ecnet}/datasets/data/mon.target | 2 +- {ecnet => src/ecnet}/datasets/data/mp.smiles | 2 +- {ecnet => src/ecnet}/datasets/data/mp.target | 2 +- {ecnet => src/ecnet}/datasets/data/pp.smiles | 2 +- {ecnet => src/ecnet}/datasets/data/pp.target | 2 +- {ecnet => src/ecnet}/datasets/data/ron.smiles | 2 +- {ecnet => src/ecnet}/datasets/data/ron.target | 2 +- {ecnet => src/ecnet}/datasets/data/ysi.smiles | 2 +- {ecnet => src/ecnet}/datasets/data/ysi.target | 2 +- src/ecnet/datasets/load_data.py | 325 ++++++++++ {ecnet => src/ecnet}/datasets/structs.py | 99 +-- {ecnet => src/ecnet}/datasets/utils.py | 49 +- {ecnet => src/ecnet}/model.py | 238 ++++--- src/ecnet/tasks/__init__.py | 17 + src/ecnet/tasks/feature_selection.py | 48 ++ src/ecnet/tasks/parameter_tuning.py | 304 +++++++++ tests/__init__.py | 0 tests/blends/test_blend_errors.py | 56 ++ tests/blends/test_linear_blends.py | 35 ++ tests/blends/test_nonlinear_blends.py | 42 ++ tests/callbacks/test_callbacks.py | 211 +++++++ tests/conftest.py | 39 ++ tests/datasets/test_lazy_backend_imports.py | 18 + tests/datasets/test_load_data.py | 52 ++ tests/datasets/test_loaders.py | 84 +++ tests/datasets/test_structs.py | 85 +++ tests/datasets/test_utils.py | 12 + tests/fixtures/.gitkeep | 0 tests/fixtures/README.md | 30 + tests/fixtures/__init__.py | 0 tests/fixtures/blend_errors.py | 122 ++++ tests/fixtures/linear_blends.py | 56 ++ tests/fixtures/nonlinear_blends.py | 125 ++++ tests/model/test_model.py | 124 ++++ tests/tasks/test_feature_selection.py | 33 + tests/tasks/test_parameter_tuning.py | 59 ++ tests/test_all.py | 240 -------- tests/test_api_signatures.py | 377 ++++++++++++ tests/test_cwd_hygiene.py | 49 ++ 118 files changed, 5091 insertions(+), 2148 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/publish_to_pypi.yml create mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/run_tests.yml create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.md create mode 100644 CITATION.cff create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 databases/README.md create mode 100644 docs/API_STABILITY.md create mode 100644 docs/RELEASING.md create mode 100644 docs/SECURITY_EXCEPTIONS.md delete mode 100644 docs/api_blends.md delete mode 100644 docs/api_callbacks.md delete mode 100644 docs/api_datasets.md delete mode 100644 docs/api_model.md delete mode 100644 docs/api_tasks.md delete mode 100644 docs/index.md create mode 100644 docs/pip-audit-ignores.txt delete mode 100644 docs/requirements.txt create mode 100644 docs/source/_static/.gitkeep create mode 100644 docs/source/_templates/.gitkeep create mode 100644 docs/source/api/blends.rst create mode 100644 docs/source/api/callbacks.rst create mode 100644 docs/source/api/datasets.rst create mode 100644 docs/source/api/ecnet.rst create mode 100644 docs/source/api/index.rst create mode 100644 docs/source/api/model.rst create mode 100644 docs/source/api/tasks.rst create mode 100644 docs/source/conf.py create mode 100644 docs/source/data.rst create mode 100644 docs/source/index.rst create mode 100644 docs/source/installation.rst create mode 100644 docs/source/quickstart.rst create mode 100644 docs/source/stability.rst create mode 100644 docs/source/units.rst delete mode 100644 ecnet/__init__.py delete mode 100644 ecnet/blends/__init__.py delete mode 100644 ecnet/datasets/__init__.py delete mode 100644 ecnet/datasets/data/.DS_Store delete mode 100644 ecnet/datasets/load_data.py delete mode 100644 ecnet/tasks/__init__.py delete mode 100644 ecnet/tasks/feature_selection.py delete mode 100644 ecnet/tasks/parameter_tuning.py delete mode 100644 mkdocs.yml create mode 100644 src/ecnet/__init__.py create mode 100644 src/ecnet/blends/__init__.py rename {ecnet => src/ecnet}/blends/equations.py (63%) rename {ecnet => src/ecnet}/blends/predict.py (92%) rename {ecnet => src/ecnet}/callbacks.py (79%) create mode 100644 src/ecnet/datasets/__init__.py create mode 100644 src/ecnet/datasets/data/README.md rename {ecnet => src/ecnet}/datasets/data/bp.smiles (99%) rename {ecnet => src/ecnet}/datasets/data/bp.target (99%) rename {ecnet => src/ecnet}/datasets/data/cn.smiles (99%) rename {ecnet => src/ecnet}/datasets/data/cn.target (99%) rename {ecnet => src/ecnet}/datasets/data/cp.smiles (96%) rename {ecnet => src/ecnet}/datasets/data/cp.target (98%) rename {ecnet => src/ecnet}/datasets/data/kv.smiles (99%) rename {ecnet => src/ecnet}/datasets/data/kv.target (99%) rename {ecnet => src/ecnet}/datasets/data/lhv.smiles (99%) rename {ecnet => src/ecnet}/datasets/data/lhv.target (99%) rename {ecnet => src/ecnet}/datasets/data/mon.smiles (99%) rename {ecnet => src/ecnet}/datasets/data/mon.target (99%) rename {ecnet => src/ecnet}/datasets/data/mp.smiles (99%) rename {ecnet => src/ecnet}/datasets/data/mp.target (99%) rename {ecnet => src/ecnet}/datasets/data/pp.smiles (99%) rename {ecnet => src/ecnet}/datasets/data/pp.target (97%) rename {ecnet => src/ecnet}/datasets/data/ron.smiles (99%) rename {ecnet => src/ecnet}/datasets/data/ron.target (99%) rename {ecnet => src/ecnet}/datasets/data/ysi.smiles (99%) rename {ecnet => src/ecnet}/datasets/data/ysi.target (99%) create mode 100644 src/ecnet/datasets/load_data.py rename {ecnet => src/ecnet}/datasets/structs.py (74%) rename {ecnet => src/ecnet}/datasets/utils.py (57%) rename {ecnet => src/ecnet}/model.py (50%) create mode 100644 src/ecnet/tasks/__init__.py create mode 100644 src/ecnet/tasks/feature_selection.py create mode 100644 src/ecnet/tasks/parameter_tuning.py create mode 100644 tests/__init__.py create mode 100644 tests/blends/test_blend_errors.py create mode 100644 tests/blends/test_linear_blends.py create mode 100644 tests/blends/test_nonlinear_blends.py create mode 100644 tests/callbacks/test_callbacks.py create mode 100644 tests/conftest.py create mode 100644 tests/datasets/test_lazy_backend_imports.py create mode 100644 tests/datasets/test_load_data.py create mode 100644 tests/datasets/test_loaders.py create mode 100644 tests/datasets/test_structs.py create mode 100644 tests/datasets/test_utils.py create mode 100644 tests/fixtures/.gitkeep create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/blend_errors.py create mode 100644 tests/fixtures/linear_blends.py create mode 100644 tests/fixtures/nonlinear_blends.py create mode 100644 tests/model/test_model.py create mode 100644 tests/tasks/test_feature_selection.py create mode 100644 tests/tasks/test_parameter_tuning.py delete mode 100644 tests/test_all.py create mode 100644 tests/test_api_signatures.py create mode 100644 tests/test_cwd_hygiene.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..453b20e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,58 @@ +name: Bug report +description: Report incorrect behavior or a crash in ECNet +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for a clear report. For security issues, email the maintainer + instead of opening a public issue (see `SECURITY.md`). + - type: textarea + id: description + attributes: + label: Description + description: What went wrong? + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Minimal code or commands that trigger the problem. + render: shell + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: input + id: version + attributes: + label: ECNet version + placeholder: e.g. 4.1.4 + validations: + required: true + - type: input + id: python + attributes: + label: Python version + placeholder: e.g. 3.12.7 + validations: + required: true + - type: input + id: os + attributes: + label: Operating system + placeholder: e.g. Ubuntu 22.04, macOS 15 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Error output + description: Paste relevant traceback or logs. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..17d9965 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/ecrl/ecnet/blob/master/SECURITY.md + about: Report security issues privately per SECURITY.md (do not use public issues). diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..168382d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,32 @@ +name: Feature request +description: Propose an enhancement that fits the current API stability policy +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Describe the use case and how it relates to the frozen public surface + documented in the Sphinx *API stability* page. Breaking API changes + require explicit maintainer approval. + - type: textarea + id: problem + attributes: + label: Problem or motivation + description: What gap does this fill? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: API sketch or workflow change (additive preferred). + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Workarounds or other approaches you evaluated. + validations: + required: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3585176 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..f36db43 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## Summary + + + +## Checklist + +- [ ] Tests added or updated for new behavior +- [ ] Docs updated if user-facing behavior or API narrative changed +- [ ] `CHANGELOG.md` `[Unreleased]` entry added when the change is user-visible +- [ ] Public import signatures remain compatible within the current series + (or a design note documents an intentional exception) +- [ ] Local checks pass (`pre-commit`, `ruff check src tests`, `pytest`) + +## Test plan + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b3a08b6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +name: CI + +on: + push: + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install package with dev extras + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Ruff check + run: ruff check src tests + - name: Ruff format + run: ruff format --check src tests + - name: Pre-commit + run: pre-commit run --all-files + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - name: Install package with dev extras + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Pytest with coverage gate + run: > + pytest tests/ -v + --cov=ecnet + --cov-report=term-missing + --cov-fail-under=90 + + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install package with dev extras + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: pip-audit + run: | + set -euo pipefail + ignore_args=() + while IFS= read -r id; do + [[ -z "${id}" || "${id}" =~ ^# ]] && continue + ignore_args+=(--ignore-vuln "${id}") + done < docs/pip-audit-ignores.txt + pip-audit "${ignore_args[@]}" + + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install package with docs extras + run: | + python -m pip install --upgrade pip + pip install -e ".[docs]" + - name: Sphinx HTML (warnings as errors) + run: sphinx-build -W -b html docs/source docs/_build/html diff --git a/.github/workflows/publish_to_pypi.yml b/.github/workflows/publish_to_pypi.yml deleted file mode 100644 index 58202db..0000000 --- a/.github/workflows/publish_to_pypi.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Upload new ECNet version to PyPI - -on: - release: - types: [published] - -permissions: - contents: read - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v3 - with: - python-version: '3.11' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8490cbd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Release to PyPI + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/ecnet + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build frontend + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build sdist and wheel + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml deleted file mode 100644 index 37c4c69..0000000 --- a/.github/workflows/run_tests.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Run ECNet tests - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Set up Python 3.11 - uses: actions/setup-python@v3 - with: - python-version: '3.11' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - pip install pytest pytest-md - - name: Install package - run: python -m pip install . - - name: Run tests - uses: pavelzw/pytest-action@v2 - with: - emoji: false - report-title: 'ECNet test report' \ No newline at end of file diff --git a/.gitignore b/.gitignore index bc3b2d3..3db241f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,9 +27,12 @@ htmlcov/ # Build / packaging dist/ build/ +wheels/ +.eggs/ *.egg-info/ *.egg pip-wheel-metadata/ +*.whl # Docs builds docs/_build/ @@ -42,6 +45,10 @@ site/ !.env.example !.env.*.example +# Legacy CWD pollution from older tests (keep package data trackable) +_temp.* +/*.pt + # OS / editor noise .DS_Store Thumbs.db diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f38c2d9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +# Local hooks. Install: pip install -e ".[dev]" && pre-commit install +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.22 + hooks: + - id: ruff + args: [--fix] + exclude: ^examples/ + - id: ruff-format + exclude: ^examples/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 661a5d1..c93a1f3 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,20 +1,20 @@ -# .readthedocs.yaml # Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +# https://docs.readthedocs.io/en/stable/config-file/v2.html -# Required version: 2 -# Set the version of Python and other tools you might need build: os: ubuntu-22.04 tools: - python: "3.11" + python: "3.12" -mkdocs: - configuration: mkdocs.yml +sphinx: + configuration: docs/source/conf.py + fail_on_warning: true -# Optionally declare the Python requirements required to build your docs python: - install: - - requirements: docs/requirements.txt \ No newline at end of file + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1078d23 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [4.1.5] — 2026-07-22 + +First modernization compatibility release. Public import signatures remain +stable relative to `4.1.4`; packaging, tests, docs, and the release path are +brought up to the approved Design A–E baseline. + +### Added + +- Contract / characterization test suite (API signatures, blends oracles, + datasets, model save/load, callbacks, tasks) with CI coverage fail-under 90%. +- `[dev]` and `[docs]` extras; Ruff and pre-commit configuration. +- Sphinx + Furo documentation (`docs/source/`), Read the Docs config, dataset + cards, units notes, and API stability page. +- Governance files: `CHANGELOG.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, + `SECURITY.md`, `CITATION.cff`, issue/PR templates. +- Dependabot; CI `pip-audit` with documented torch exceptions + (`docs/SECURITY_EXCEPTIONS.md`). +- PyPI trusted publishing via OIDC (`.github/workflows/release.yml`); maintainer + steps in `docs/RELEASING.md`. +- Refreshed `examples/` notebooks (seeded demos, train-only scaling where + needed, stripped outputs). + +### Changed + +- Package layout moved to `src/ecnet/`; `ecnet.__version__` via + `importlib.metadata`. +- Runtime dependencies use compatible version ranges instead of exact pins + (`torch>=2.4.0,<2.6`, `scikit-learn>=1.5.1,<2`, and matching ranges for + `padelpy`, `alvadescpy`, and `ecabc`). +- `ECNet.save` writes an `ecnet-state-v1` checkpoint (architecture metadata plus + `state_dict`). `load_model` still loads legacy full-module `.pt` pickles with + the same signature and prediction behavior. +- CI matrix on Ubuntu for Python 3.11–3.12 (lint, tests, audit, docs). +- Descriptor backends (`padel` / `alvadesc`) are imported lazily so PaDEL-only + installs are not blocked by `alvadescpy` / `pkg_resources`. +- `databases/` documented as a research archive (not installed in wheels). + +### Fixed + +- Validation loss is recorded after each epoch’s `Validator` evaluation (no + `sys.maxsize` sentinel / one-epoch lag in returned histories). +- `QSPRDataset.set_index` / `set_desc_index` use tensor indexing (avoids slow + list-of-ndarray tensor construction warnings). +- Training and save/load tests no longer leave fixed-name temporary files in the + process working directory. + +[Unreleased]: https://github.com/ecrl/ecnet/compare/4.1.5...HEAD +[4.1.5]: https://github.com/ecrl/ecnet/compare/4.1.4...4.1.5 diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..7c8cf5c --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,43 @@ +cff-version: 1.2.0 +message: If you use this software, please cite it using the metadata below. +title: "ECNet: machine learning models for fuel property prediction" +authors: + - family-names: Kessler + given-names: Travis + orcid: https://orcid.org/0000-0002-7363-4050 + email: travis.j.kessler@gmail.com + - family-names: Mack + given-names: John Hunter + orcid: https://orcid.org/0000-0002-5455-8611 + email: Hunter_Mack@uml.edu +version: 4.1.5 +license: MIT +repository-code: https://github.com/ecrl/ecnet +url: https://ecnet.readthedocs.io/en/latest/ +abstract: >- + ECNet predicts fuel properties from molecular structure using QSPR + descriptors and multilayer perceptron models, with bundled property + datasets, tuning helpers, and analytical blend equations. +keywords: + - fuel property prediction + - QSPR + - machine learning + - cetane number + - PyTorch +preferred-citation: + type: article + authors: + - family-names: Kessler + given-names: Travis + orcid: https://orcid.org/0000-0002-7363-4050 + - family-names: Mack + given-names: John Hunter + orcid: https://orcid.org/0000-0002-5455-8611 + title: "ECNet: Large scale machine learning projects for fuel property prediction" + journal: Journal of Open Source Software + year: 2017 + volume: "2" + issue: "17" + start: 401 + doi: 10.21105/joss.00401 + url: https://doi.org/10.21105/joss.00401 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9cf3df6 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,129 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +travis.j.kessler@gmail.com. All complaints will be reviewed and investigated +promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact:** Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence:** A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact:** A violation through a single incident or series of +actions. + +**Consequence:** A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact:** A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence:** A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact:** Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence:** A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. + +[homepage]: https://www.contributor-covenant.org +[Mozilla CoC]: https://github.com/mozilla/diversity diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..45b7f7e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Contributing to ECNet + +Thank you for contributing. This note covers local development, checks, and +pull-request expectations for the current compatibility series. + +Please also read the [Code of Conduct](CODE_OF_CONDUCT.md). Security issues +should be reported privately per [SECURITY.md](SECURITY.md). + +## Development environment + +Requires Python 3.11 or newer. + +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev]" +pre-commit install +``` + +Optional documentation dependencies: + +```bash +pip install -e ".[docs]" +``` + +## Checks before opening a pull request + +```bash +pre-commit run --all-files +ruff check src tests +ruff format --check src tests +pytest tests/ -v --cov=ecnet --cov-report=term-missing +``` + +Docs (when you change Sphinx pages or docstrings that affect autodoc). The CI +`docs` job runs the same command with warnings as errors: + +```bash +sphinx-build -W -b html docs/source docs/_build/html +``` + +Dependency audit (same pattern as the CI `audit` job; ignores are listed in +`docs/SECURITY_EXCEPTIONS.md`): + +```bash +ignore_args=() +while IFS= read -r id; do + [[ -z "$id" || "$id" =~ ^# ]] && continue + ignore_args+=(--ignore-vuln "$id") +done < docs/pip-audit-ignores.txt +pip-audit "${ignore_args[@]}" +``` + +Hooks run Ruff lint/format and basic file hygiene on staged changes. Configure +them once with `pre-commit install` after installing the `[dev]` extra. + +## Example notebooks + +Notebooks under `examples/` demonstrate the current v4 API with the default +PaDEL backend (Java required). They are **not** run in CI. + +Manual smoke (optional, after `pip install -e ".[dev]"` and a working Java +install): + +```bash +jupyter execute examples/getting_started.ipynb +# or open the notebooks in Jupyter and Run All +``` + +Commit notebooks **without** stored outputs. Do not reintroduce +`backend="alvadesc"` in committed examples unless the change is clearly marked +as license-gated. + +## Pull request checklist + +- [ ] Tests added or updated for new behavior +- [ ] Docs updated if user-facing behavior changed +- [ ] `CHANGELOG.md` `[Unreleased]` entry for user-visible changes +- [ ] Public import signatures remain stable within the current compatibility + series unless a design note documents otherwise +- [ ] Example notebook outputs left cleared when notebooks change + +File issues for bugs or feature requests using the GitHub templates. Include +OS, Python version, and relevant error output for bugs. + +## Releasing + +Maintainers: see [docs/RELEASING.md](docs/RELEASING.md) for PyPI trusted +publishing (OIDC), the `pypi` GitHub Environment, and cutting a GitHub Release. diff --git a/README.md b/README.md index dad733c..e87c2be 100644 --- a/README.md +++ b/README.md @@ -4,27 +4,84 @@ [![GitHub version](https://badge.fury.io/gh/ecrl%2FECNet.svg)](https://badge.fury.io/gh/ecrl%2FECNet) [![PyPI version](https://badge.fury.io/py/ecnet.svg)](https://badge.fury.io/py/ecnet) -[![status](http://joss.theoj.org/papers/f556afbc97e18e1c1294d98e0f7ff99f/status.svg)](http://joss.theoj.org/papers/f556afbc97e18e1c1294d98e0f7ff99f) +[![status](https://joss.theoj.org/papers/10.21105/joss.00401/status.svg)](https://doi.org/10.21105/joss.00401) [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/ECRL/ECNet/master/LICENSE.txt) [![Documentation Status](https://readthedocs.org/projects/ecnet/badge/?version=latest)](https://ecnet.readthedocs.io/en/latest/?badge=latest) - -**ECNet** is an open source Python package for creating machine learning models to predict fuel properties. ECNet comes bundled with a variety of fuel property datasets, including cetane number, yield sooting index, and research/motor octane number. ECNet was built using the [PyTorch](https://pytorch.org/) library, allowing easy implementation of our models in your existing ML pipelines. -ECNet leverages [QSPR descriptors](https://en.wikipedia.org/wiki/Quantitative_structure%E2%80%93activity_relationship) for use as input variables, specifically [PaDEL-Descriptor](http://www.yapcwsoft.com/dd/padeldescriptor/) and [alvaDesc](https://www.alvascience.com/alvadesc/). Using alvaDesc requires a valid license. +**ECNet** is an open-source Python package for predicting fuel properties from +molecular structure using quantitative structure–property relationship (QSPR) +descriptors and multilayer perceptron models built with +[PyTorch](https://pytorch.org/). -Future plans for ECNet include: -- Implementating RDKit to train using molecular fingerprints -- Leveraging additional QSPR-generation software packages (e.g. [Mordred](https://github.com/mordred-descriptor/mordred)) -- A graphical user interface +The current **v4** API centers on `ECNet`, bundled property loaders +(`ecnet.datasets.load_*`), hyperparameter-tuning helpers, training callbacks, +and analytical blend-property equations. Descriptor backends include +[PaDEL-Descriptor](http://www.yapcwsoft.com/dd/padeldescriptor/) (default) and +[alvaDesc](https://www.alvascience.com/alvadesc/) (optional; requires a valid +license). -# Installation and Usage +## Installation -Please refer to our [documentation page](https://ecnet.readthedocs.io/en/latest/) for installation instructions and full API documentation. You can also view some [example scripts](https://github.com/ECRL/ECNet/tree/master/examples) we put together. +Requires **Python 3.11** or newer. Java is needed for the default PaDEL backend. -# Contributing, Reporting Issues, and Other Support: +```bash +pip install ecnet +``` -To contribute to ECNet, make a pull request. Contributions should include tests for new features added, as well as extensive documentation. +From a clone of this repository: -To report problems with the software or feature requests, file an issue. When reporting problems, include information such as error messages, your OS/environment and Python version. +```bash +pip install -e . +pip install -e ".[dev]" # pytest, ruff, pre-commit, pip-audit +pip install -e ".[docs]" # Sphinx + Furo +``` -For additional support/questions, contact Travis Kessler (Travis_Kessler@student.uml.edu) and/or John Hunter Mack (Hunter_Mack@uml.edu). +## Documentation and examples + +- User guide and API reference: [ecnet.readthedocs.io](https://ecnet.readthedocs.io/en/latest/) +- Example notebooks: [`examples/`](https://github.com/ecrl/ecnet/tree/master/examples) +- Stability policy: Sphinx *API stability* page (source: `docs/source/stability.rst`) +- Bundled dataset cards: Sphinx *Bundled property datasets* page (`docs/source/data.rst`) + +## Historical JOSS architecture note + +The 2017 Journal of Open Source Software article +([doi:10.21105/joss.00401](https://doi.org/10.21105/joss.00401)) and the +accompanying `paper/paper.md` describe a **prior generation** of ECNet based on +a project / build / node ensemble workflow. That architecture is **not** the +current public API. For v4 usage, follow the Sphinx documentation and the +imports documented under `ecnet`, `ecnet.datasets`, `ecnet.tasks`, +`ecnet.blends`, and `ecnet.callbacks`. The JOSS paper remains an appropriate +citation for the software’s publication history. + +## Citation + +If you use ECNet in scholarly work, please cite: + +Kessler, T., & Mack, J. H. (2017). ECNet: Large scale machine learning projects +for fuel property prediction. *Journal of Open Source Software*, 2(17), 401. +https://doi.org/10.21105/joss.00401 + +```bibtex +@article{Kessler2017, + doi = {10.21105/joss.00401}, + url = {https://doi.org/10.21105/joss.00401}, + year = {2017}, + publisher = {The Open Journal}, + volume = {2}, + number = {17}, + pages = {401}, + author = {Kessler, Travis and Mack, John Hunter}, + title = {ECNet: Large scale machine learning projects for fuel property prediction}, + journal = {Journal of Open Source Software} +} +``` + +## Contributing and support + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for local development setup, hooks, and +checks. Report bugs and feature requests via GitHub issues (include OS, Python +version, and relevant error output). + +Contact: Travis Kessler () and John Hunter Mack +(). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fc22513 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security policy + +## Supported versions + +Security fixes are considered for the current published release line on PyPI +(the latest `4.1.x` / compatibility series under active maintenance). + +## Reporting a vulnerability + +Please report security vulnerabilities **privately** by email to +travis.j.kessler@gmail.com. + +Do **not** open a public GitHub issue for security bugs. Include a clear +description of the issue, steps to reproduce when possible, and any known +impact. + +You should expect an acknowledgment within a reasonable time. We will discuss +next steps, including disclosure timing, with the reporter. + +## Dependency advisories + +Known accepted dependency audit exceptions for CI `pip-audit` (for example +torch advisories under the current upper bound) are documented in +`docs/SECURITY_EXCEPTIONS.md`. That file is for dependency policy tracking; it +is not the channel for reporting new project vulnerabilities. diff --git a/databases/README.md b/databases/README.md new file mode 100644 index 0000000..2331c0a --- /dev/null +++ b/databases/README.md @@ -0,0 +1,24 @@ +# `databases/` — research archive (not installed) + +This directory holds large CSV masters and a small filter helper used in +historical data curation workflows: + +- `properties_master.csv` — property table +- `descriptors_master.csv` — descriptor table (~64 MB) +- `filter_property.py` — filtering helper + +## Install status + +**These files are not part of the ECNet wheel or sdist.** Packaging excludes +`databases*` from the installable package. Installing from PyPI or +`pip install -e .` does **not** require this directory. + +Bundled property sets used by `ecnet.datasets.load_*` live under +`src/ecnet/datasets/data/` (`.smiles` / `.target` pairs) and are documented in +the Sphinx page *Bundled property datasets* (`docs/source/data.rst`). + +## Intended use + +Treat `databases/` as a **repository research archive** for maintainers who +clone the full git tree. Downstream users who only need prediction and the +bundled loaders can ignore this directory. diff --git a/databases/filter_property.py b/databases/filter_property.py index add286e..06f55c5 100644 --- a/databases/filter_property.py +++ b/databases/filter_property.py @@ -2,22 +2,24 @@ from typing import List _properties = [ - 'cetane_number', - 'research_octane_number', - 'motor_octane_number', - 'octane_sensitivity', - 'ysi_unified', - 'autoignition_temp', - 'boiling_point', - 'flash_point', - 'heat_of_vaporization', - 'melting_point', - 'kinematic_viscosity', - 'pour_point' + "cetane_number", + "research_octane_number", + "motor_octane_number", + "octane_sensitivity", + "ysi_unified", + "autoignition_temp", + "boiling_point", + "flash_point", + "heat_of_vaporization", + "melting_point", + "kinematic_viscosity", + "pour_point", ] -def get_descriptors(cas: List[str], db_loc: str = 'descriptors_master.csv') -> List[dict]: +def get_descriptors( + cas: List[str], db_loc: str = "descriptors_master.csv" +) -> List[dict]: r""" Obtains all descriptors for a list of compounds (list of their CAS numbers) @@ -31,21 +33,21 @@ def get_descriptors(cas: List[str], db_loc: str = 'descriptors_master.csv') -> L Descriptor, plus CAS number """ - with open(db_loc, 'r') as csv_file: + with open(db_loc, "r") as csv_file: reader = DictReader(csv_file) compounds = [c for c in reader] csv_file.close() keys = list(compounds[0].keys()) - keys.remove('cas') + keys.remove("cas") to_return = [] for cs in cas: found = False for comp in compounds: - if cs == comp['cas']: + if cs == comp["cas"]: for key in keys: - if comp[key] == 'na' or comp[key] == '' or comp[key] == '-': + if comp[key] == "na" or comp[key] == "" or comp[key] == "-": comp[key] = 0.0 else: comp[key] = float(comp[key]) @@ -53,11 +55,11 @@ def get_descriptors(cas: List[str], db_loc: str = 'descriptors_master.csv') -> L found = True break if not found: - raise IndexError('Could not find descriptors for CAS `{}`'.format(cs)) + raise IndexError("Could not find descriptors for CAS `{}`".format(cs)) return to_return -def get_compounds(prop: str, db_loc: str = 'properties_master.csv') -> List[dict]: +def get_compounds(prop: str, db_loc: str = "properties_master.csv") -> List[dict]: r""" Returns all compounds that have experimental data for user-specified property @@ -84,16 +86,18 @@ def get_compounds(prop: str, db_loc: str = 'properties_master.csv') -> List[dict """ if prop not in _properties: - raise ValueError('{} not found in available properties: {}'.format(prop, _properties)) + raise ValueError( + "{} not found in available properties: {}".format(prop, _properties) + ) - with open(db_loc, 'r') as csv_file: + with open(db_loc, "r") as csv_file: reader = DictReader(csv_file) compounds = [c for c in reader] csv_file.close() to_return = [] for comp in compounds: - if comp['properties.{}.value'.format(prop)] != '-': + if comp["properties.{}.value".format(prop)] != "-": to_return.append(comp) else: continue @@ -101,21 +105,20 @@ def get_compounds(prop: str, db_loc: str = 'properties_master.csv') -> List[dict # Example: get property values, descriptors for database subset w/ cetane number data -if __name__ == '__main__': - +if __name__ == "__main__": # Obtain compounds with experimental cetane number data - comps = get_compounds('cetane_number') + comps = get_compounds("cetane_number") # Obtain descriptors for compounds with experimental cetane number data - desc = get_descriptors([c['cas'] for c in comps]) + desc = get_descriptors([c["cas"] for c in comps]) # Get SMILES strings, cetane number values, descriptors for downstream processing - smiles = [c['canonical_smiles'] for c in comps] - cn = [float(c['properties.cetane_number.value']) for c in comps] + smiles = [c["canonical_smiles"] for c in comps] + cn = [float(c["properties.cetane_number.value"]) for c in comps] keys = list(desc[0].keys()) - keys.remove('cas') + keys.remove("cas") desc = [[d[k] for k in keys] for d in desc] - print('Number of SMILES strings: {}'.format(len(smiles))) - print('Number of cetane number values: {}'.format(len(cn))) - print('Shape of descriptors matrix: ({}, {})'.format(len(desc), len(desc[0]))) + print("Number of SMILES strings: {}".format(len(smiles))) + print("Number of cetane number values: {}".format(len(cn))) + print("Shape of descriptors matrix: ({}, {})".format(len(desc), len(desc[0]))) diff --git a/docs/API_STABILITY.md b/docs/API_STABILITY.md new file mode 100644 index 0000000..f312b02 --- /dev/null +++ b/docs/API_STABILITY.md @@ -0,0 +1,13 @@ +# ECNet API stability policy + +The canonical stability and versioning policy for the `4.1.x` / `4.2.x` +compatibility series lives in the Sphinx docs: + +- Source: [`docs/source/stability.rst`](source/stability.rst) +- Built site: API stability page (Read the Docs / local `docs/_build/html`) + +Autodoc for the frozen public surface is under `docs/source/api/`. Units and +numeric conventions are in `docs/source/units.rst`. + +This file remains as a repository pointer for links and browsers that do not +build Sphinx. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..99bc963 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,63 @@ +# Releasing ECNet + +Maintainers cut compatibility releases with a GitHub Release, which runs +`.github/workflows/release.yml` and uploads to PyPI via **trusted publishing** +(OIDC). No long-lived PyPI API token is stored in GitHub secrets. + +## One-time portal setup (human click-through) + +Complete these steps once before the first OIDC publish (or after changing +the workflow filename / environment name). + +### 1. GitHub Environment + +1. Open the repository **Settings → Environments**. +2. Create an environment named exactly `pypi`. +3. Optional but recommended: require reviewers or restrict which branches may + deploy to `pypi`. +4. Do **not** add a `PYPI_API_TOKEN` secret for this flow. + +### 2. PyPI trusted publisher + +1. Sign in at [pypi.org](https://pypi.org/) as a project owner/maintainer for + `ecnet`. +2. Open **Publishing** for the project (or **Pending publisher** if configuring + before the next upload). +3. Add a GitHub publisher with: + - **Owner:** `ecrl` + - **Repository:** `ecnet` + - **Workflow:** `release.yml` + - **Environment:** `pypi` +4. Save. PyPI documents the full UI at + [Trusted publishers](https://docs.pypi.org/trusted-publishers/). + +After the first successful OIDC upload, remove any unused legacy +`PYPI_API_TOKEN` from GitHub secrets if one remains. + +## Cut a release + +1. Ensure `main` (or the release branch) is green on CI and version / CHANGELOG + match the intended tag (see blueprint Phase 5 / Design E). +2. Push the release commit, then create an annotated git tag + (for example `4.1.5`) and a GitHub Release for that tag. +3. Publishing the GitHub Release triggers `release.yml`. You may also run the + workflow manually via **Actions → Release to PyPI → Run workflow** + (`workflow_dispatch`) on the tagged commit if needed. +4. Confirm the new files appear on [PyPI: ecnet](https://pypi.org/project/ecnet/). +5. Smoke-install in a clean virtualenv: + + ```bash + python -m venv /tmp/ecnet-smoke && source /tmp/ecnet-smoke/bin/activate + pip install ecnet== + python -c "import ecnet; print(ecnet.__version__)" + ``` + +## Troubleshooting + +- **OIDC / publisher mismatch:** Workflow filename, environment name, owner, + and repository must match the PyPI trusted-publisher row exactly + (`release.yml`, environment `pypi`, `ecrl/ecnet`). +- **Environment protection:** If the `pypi` environment requires reviewers, + approve the deployment in the GitHub Actions UI after the job starts. +- **Do not** reintroduce `password: ${{ secrets.PYPI_API_TOKEN }}` into the + release workflow. diff --git a/docs/SECURITY_EXCEPTIONS.md b/docs/SECURITY_EXCEPTIONS.md new file mode 100644 index 0000000..1cca9b2 --- /dev/null +++ b/docs/SECURITY_EXCEPTIONS.md @@ -0,0 +1,40 @@ +# Accepted `pip-audit` exceptions + +This file records vulnerability IDs that CI ignores after review. Each entry +must include rationale and an expiry date. Do not add ignores without updating +`docs/pip-audit-ignores.txt` (the machine-readable list consumed by CI). + +## Active exceptions + +### Torch advisories under `torch>=2.4.0,<2.6` + +| Field | Value | +|-------|--------| +| Package | `torch` (resolved 2.5.1 in a clean `[dev]` install on 2026-07-22) | +| Constraint | `torch>=2.4.0,<2.6` (Phase C / T3.1; characterization suite proven on 2.5.1) | +| Expiry | **2026-10-22** | +| Rationale | Published fixes require torch 2.6–2.13, which is outside the current upper bound. Raising the bound needs a dedicated oracle re-proof, not a silent CI ignore expansion. | + +Ignored IDs (also listed in `docs/pip-audit-ignores.txt`): + +- `CVE-2025-2148`, `CVE-2025-2149`, `CVE-2025-2998`, `CVE-2025-2999`, `CVE-2025-3001` +- `PYSEC-2025-41`, `PYSEC-2025-191`, `PYSEC-2025-194`, `PYSEC-2025-198` +- `PYSEC-2025-203`, `PYSEC-2025-204`, `PYSEC-2025-205`, `PYSEC-2025-206` +- `PYSEC-2025-207`, `PYSEC-2025-208`, `PYSEC-2025-209` +- `PYSEC-2026-139`, `PYSEC-2026-1970`, `PYSEC-2026-2286` + +**Follow-up:** Before expiry, re-run the characterization suite against a wider +torch range (for example `>=2.4,<2.8` or higher as wheels allow), then remove +these ignores and tighten or drop this exception. + +## Local audit + +```bash +pip install -e ".[dev]" +ignore_args=() +while IFS= read -r id; do + [[ -z "$id" || "$id" =~ ^# ]] && continue + ignore_args+=(--ignore-vuln "$id") +done < docs/pip-audit-ignores.txt +pip-audit "${ignore_args[@]}" +``` diff --git a/docs/api_blends.md b/docs/api_blends.md deleted file mode 100644 index 90b2bc2..0000000 --- a/docs/api_blends.md +++ /dev/null @@ -1,26 +0,0 @@ -# ecnet.blends - -## ecnet.blends.cetane_number - -::: ecnet.blends.cetane_number - handler: python - -## ecnet.blends.yield_sooting_index - -::: ecnet.blends.yield_sooting_index - handler: python - -## ecnet.blends.kinematic_viscosity - -::: ecnet.blends.kinematic_viscosity - handler: python - -## ecnet.blends.cloud_point - -::: ecnet.blends.cloud_point - handler: python - -## ecnet.blends.lower_heating_value - -::: ecnet.blends.lower_heating_value - handler: python \ No newline at end of file diff --git a/docs/api_callbacks.md b/docs/api_callbacks.md deleted file mode 100644 index a497674..0000000 --- a/docs/api_callbacks.md +++ /dev/null @@ -1,21 +0,0 @@ -# ecnet.callbacks - -## ecnet.callbacks.CallbackOperator - -::: ecnet.callbacks.CallbackOperator - handler: python - -## ecnet.callbacks.Callback - -::: ecnet.callbacks.Callback - handler: python - -## ecnet.callbacks.LRDecayLinear - -::: ecnet.callbacks.LRDecayLinear - handler: python - -## ecnet.callbacks.Validator - -::: ecnet.callbacks.Validator - handler: python \ No newline at end of file diff --git a/docs/api_datasets.md b/docs/api_datasets.md deleted file mode 100644 index c316633..0000000 --- a/docs/api_datasets.md +++ /dev/null @@ -1,66 +0,0 @@ -# ecnet.datasets - -## ecnet.datasets.QSPRDataset - -::: ecnet.datasets.QSPRDataset - handler: python - -## ecnet.datasets.QSPRDatasetFromFile - -::: ecnet.datasets.QSPRDatasetFromFile - handler: python - -## ecnet.datasets.QSPRDatasetFromValues - -::: ecnet.datasets.QSPRDatasetFromValues - handler: python - -## ecnet.datasets.load_bp - -::: ecnet.datasets.load_bp - handler: python - -## ecnet.datasets.load_cn - -::: ecnet.datasets.load_cn - handler: python - -## ecnet.datasets.load_cp - -::: ecnet.datasets.load_cp - handler: python - -## ecnet.datasets.load_kv - -::: ecnet.datasets.load_kv - handler: python - -## ecnet.datasets.load_lhv - -::: ecnet.datasets.load_lhv - handler: python - -## ecnet.datasets.load_mon - -::: ecnet.datasets.load_mon - handler: python - -## ecnet.datasets.load_pp - -::: ecnet.datasets.load_pp - handler: python - -## ecnet.datasets.load_ron - -::: ecnet.datasets.load_ron - handler: python - -## ecnet.datasets.load_ysi - -::: ecnet.datasets.load_ysi - handler: python - -## ecnet.datasets.load_mp - -::: ecnet.datasets.load_mp - handler: python \ No newline at end of file diff --git a/docs/api_model.md b/docs/api_model.md deleted file mode 100644 index cf07377..0000000 --- a/docs/api_model.md +++ /dev/null @@ -1,4 +0,0 @@ -# ecnet.ECNet - -::: ecnet.ECNet - handler: python \ No newline at end of file diff --git a/docs/api_tasks.md b/docs/api_tasks.md deleted file mode 100644 index f421408..0000000 --- a/docs/api_tasks.md +++ /dev/null @@ -1,21 +0,0 @@ -# ecnet.tasks - -## ecnet.tasks.select_rfr - -::: ecnet.tasks.select_rfr - handler: python - -## ecnet.tasks.tune_batch_size - -::: ecnet.tasks.tune_batch_size - handler: python - -## ecnet.tasks.tune_model_architecture - -::: ecnet.tasks.tune_model_architecture - handler: python - -## ecnet.tasks.tune_training_parameters - -::: ecnet.tasks.tune_training_parameters - handler: python \ No newline at end of file diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md index ec0cdf1..4ee1e64 100644 --- a/docs/design/DESIGN.md +++ b/docs/design/DESIGN.md @@ -1,10 +1,10 @@ # ECNet: API-stable modernization of QSPR-based fuel property prediction -**Design Document — v0.1** -**Status:** Approved (2026-07-21) -**Package:** `ecnet` (PyPI / import name unchanged) -**License:** MIT (unchanged) -**Current release baseline:** `4.1.4` (2024-08-29) +**Design Document — v0.1** +**Status:** Approved (2026-07-21) +**Package:** `ecnet` (PyPI / import name unchanged) +**License:** MIT (unchanged) +**Current release baseline:** `4.1.4` (2024-08-29) **First modernization tag:** `4.1.5` (after Phases A and B) --- @@ -274,42 +274,42 @@ Numerical assertions use `pytest.approx` with tolerances justified per test (ble ### 9.1 `ecnet` (`__init__.py`) -**Purpose:** Export `ECNet` and `__version__`. +**Purpose:** Export `ECNet` and `__version__`. **Changes:** Replace `pkg_resources` with `importlib.metadata.version("ecnet")`; add explicit `__all__`. ### 9.2 `ecnet.model` -**Purpose:** `ECNet` MLP, training loop, save/load. -**Preserve:** Constructor args; `fit` defaults; MSE loss; ReLU between layers; validation/early-stopping semantics when `valid_size > 0`. +**Purpose:** `ECNet` MLP, training loop, save/load. +**Preserve:** Constructor args; `fit` defaults; MSE loss; ReLU between layers; validation/early-stopping semantics when `valid_size > 0`. **Internal improvements (Phase C):** `load_model` shim that accepts legacy full-module `.pt` pickles and a newer state-dict format, without changing the `load_model(path)` signature; avoid CWD pollution; keep `save` requiring `.pt` extension (prefer writing the new format going forward while remaining able to read legacy files). ### 9.3 `ecnet.datasets` -**Purpose:** QSPR dataset types, property loaders, PaDEL/alvaDesc utilities. -**Preserve:** Loader names and `(smiles, targets)` vs `QSPRDataset` return modes; default `backend='padel'`. +**Purpose:** QSPR dataset types, property loaders, PaDEL/alvaDesc utilities. +**Preserve:** Loader names and `(smiles, targets)` vs `QSPRDataset` return modes; default `backend='padel'`. **Improvements:** Dataset cards (Phase D/G10); tests for all `load_*`; document `PCADataset` export policy. ### 9.4 `ecnet.tasks` -**Purpose:** Random-forest feature selection and ABC-based hyperparameter tuning (`ecabc`). -**Preserve:** Function names and return dict key structures used by callers/tests. +**Purpose:** Random-forest feature selection and ABC-based hyperparameter tuning (`ecabc`). +**Preserve:** Function names and return dict key structures used by callers/tests. **Tests:** Keep short-iteration tuning tests; mark slow variants if expanded. ### 9.5 `ecnet.blends` -**Purpose:** Analytical blend property predictors and error propagation helpers. -**Preserve:** Equations and units. +**Purpose:** Analytical blend property predictors and error propagation helpers. +**Preserve:** Equations and units. **Priority:** Highest test value in Phase A (pure functions, currently 0% coverage). ### 9.6 `ecnet.callbacks` -**Purpose:** Training callbacks (`LRDecayLinear`, `Validator`, operator plumbing). -**Preserve:** Callback method contracts used by `ECNet.fit`. +**Purpose:** Training callbacks (`LRDecayLinear`, `Validator`, operator plumbing). +**Preserve:** Callback method contracts used by `ECNet.fit`. **Tests:** Replace the no-op `Validator` test with a real early-stopping characterization test. ### 9.7 `databases/` (repo root) -**Purpose today:** Large CSV masters + filter script; not part of the installed wheel. +**Purpose today:** Large CSV masters + filter script; not part of the installed wheel. **Approved:** keep out of wheels and sdists as a non-install research archive; add a README/dataset card clarifying that role (Phase D / G10). Do not silently bundle into PyPI artifacts. --- diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 09a0de0..0000000 --- a/docs/index.md +++ /dev/null @@ -1,29 +0,0 @@ -# ECNet Documentation - -[![UML Energy & Combustion Research Laboratory](https://sites.uml.edu/hunter-mack/files/2021/11/ECRL_final.png)](http://faculty.uml.edu/Hunter_Mack/) - -[![GitHub version](https://badge.fury.io/gh/ecrl%2FECNet.svg)](https://badge.fury.io/gh/ecrl%2FECNet) -[![PyPI version](https://badge.fury.io/py/ecnet.svg)](https://badge.fury.io/py/ecnet) -[![status](http://joss.theoj.org/papers/f556afbc97e18e1c1294d98e0f7ff99f/status.svg)](http://joss.theoj.org/papers/f556afbc97e18e1c1294d98e0f7ff99f) -[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/ECRL/ECNet/master/LICENSE.txt) -[![Documentation Status](https://readthedocs.org/projects/ecnet/badge/?version=latest)](https://ecnet.readthedocs.io/en/latest/?badge=latest) - -## Installation - -Installation requires Python 3.11+. - -### Installation via pip - - pip install ecnet - -Additional dependencies (torch, sklearn, padelpy, alvadescpy, ecabc) will be installed during ECNet's installation process. If you have any trouble with these dependencies (or want to compile them yourself, e.g. PyTorch with GPU support), consider installing them from source before installing ECNet. - -### Upgrading via pip - - pip install --upgrade ecnet - -### Installation from source - - git clone https://github.com/ecrl/ecnet - cd ecnet - pip install . diff --git a/docs/pip-audit-ignores.txt b/docs/pip-audit-ignores.txt new file mode 100644 index 0000000..2be98bf --- /dev/null +++ b/docs/pip-audit-ignores.txt @@ -0,0 +1,21 @@ +# Machine-readable ignores for CI `pip-audit`. +# Keep in sync with docs/SECURITY_EXCEPTIONS.md (torch <2.6 bound). +CVE-2025-2148 +CVE-2025-2149 +CVE-2025-2998 +CVE-2025-2999 +CVE-2025-3001 +PYSEC-2025-191 +PYSEC-2025-194 +PYSEC-2025-198 +PYSEC-2025-203 +PYSEC-2025-204 +PYSEC-2025-205 +PYSEC-2025-206 +PYSEC-2025-207 +PYSEC-2025-208 +PYSEC-2025-209 +PYSEC-2025-41 +PYSEC-2026-139 +PYSEC-2026-1970 +PYSEC-2026-2286 diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 9c81b6a..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -mkdocs-material -mkdocstrings-python \ No newline at end of file diff --git a/docs/source/_static/.gitkeep b/docs/source/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/_templates/.gitkeep b/docs/source/_templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/api/blends.rst b/docs/source/api/blends.rst new file mode 100644 index 0000000..f34b0ea --- /dev/null +++ b/docs/source/api/blends.rst @@ -0,0 +1,10 @@ +``ecnet.blends`` +================ + +Analytical blend-property predictors and error helpers. See :doc:`../units` +for cloud point (°C) and kinematic viscosity (cSt) conventions. + +.. automodule:: ecnet.blends + :members: + :imported-members: + :undoc-members: diff --git a/docs/source/api/callbacks.rst b/docs/source/api/callbacks.rst new file mode 100644 index 0000000..796aecc --- /dev/null +++ b/docs/source/api/callbacks.rst @@ -0,0 +1,9 @@ +``ecnet.callbacks`` +=================== + +Training callbacks used by ``ECNet.fit``. + +.. automodule:: ecnet.callbacks + :members: CallbackOperator, Callback, LRDecayLinear, Validator + :show-inheritance: + :undoc-members: diff --git a/docs/source/api/datasets.rst b/docs/source/api/datasets.rst new file mode 100644 index 0000000..b1573e9 --- /dev/null +++ b/docs/source/api/datasets.rst @@ -0,0 +1,18 @@ +``ecnet.datasets`` +================== + +Bundled property loaders and QSPR dataset types. + +.. automodule:: ecnet.datasets + :members: + :imported-members: + :undoc-members: + +Advanced import +--------------- + +``PCADataset`` is **not** re-exported here. Use: + +.. code-block:: python + + from ecnet.datasets.structs import PCADataset diff --git a/docs/source/api/ecnet.rst b/docs/source/api/ecnet.rst new file mode 100644 index 0000000..29512e3 --- /dev/null +++ b/docs/source/api/ecnet.rst @@ -0,0 +1,14 @@ +``ecnet`` +========= + +Top-level package exports. Prefer ``from ecnet import ECNet``. + +.. autodata:: ecnet.__version__ + :annotation: + +.. autoclass:: ecnet.model.ECNet + :members: + :special-members: __init__ + :show-inheritance: + + Imported in user code as ``ecnet.ECNet``. diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst new file mode 100644 index 0000000..8c412b4 --- /dev/null +++ b/docs/source/api/index.rst @@ -0,0 +1,15 @@ +API reference +============= + +Autodoc for the frozen public surface of the current v4 API. Stability and +versioning rules are in :doc:`../stability`. + +.. toctree:: + :maxdepth: 1 + + ecnet + model + datasets + tasks + blends + callbacks diff --git a/docs/source/api/model.rst b/docs/source/api/model.rst new file mode 100644 index 0000000..b4cac40 --- /dev/null +++ b/docs/source/api/model.rst @@ -0,0 +1,7 @@ +``ecnet.model`` +=============== + +Model persistence helpers. The ``ECNet`` class is documented under +:doc:`ecnet` (import path ``from ecnet import ECNet``). + +.. autofunction:: ecnet.model.load_model diff --git a/docs/source/api/tasks.rst b/docs/source/api/tasks.rst new file mode 100644 index 0000000..bef3577 --- /dev/null +++ b/docs/source/api/tasks.rst @@ -0,0 +1,9 @@ +``ecnet.tasks`` +=============== + +Feature selection and hyperparameter-tuning helpers. + +.. automodule:: ecnet.tasks + :members: + :imported-members: + :undoc-members: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..11cf353 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,103 @@ +"""Sphinx configuration for ECNet.""" + +from __future__ import annotations + +import sys +import warnings +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) + +# Third-party import noise must not fail `sphinx-build -W`. +warnings.filterwarnings( + "ignore", + message="pkg_resources is deprecated", + category=UserWarning, +) +warnings.filterwarnings( + "ignore", + message=".*TorchScript.*", + category=DeprecationWarning, +) +warnings.filterwarnings( + "ignore", + message=".*reduce_op.*", + category=FutureWarning, +) + +project = "ECNet" +author = "Travis Kessler" +copyright = f"{datetime.now(tz=timezone.utc).year}, {author}" +release = "4.1.5" +version = "4.1" + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx_autodoc_typehints", + "sphinx_copybutton", + "myst_parser", +] + +templates_path: list[str] = ["_templates"] +exclude_patterns: list[str] = [] + +html_theme = "furo" +html_static_path: list[str] = ["_static"] + +napoleon_google_docstring = False +napoleon_numpy_docstring = True +autodoc_typehints = "description" +autodoc_member_order = "bysource" + +# Avoid pulling optional descriptor stacks into every autodoc import graph. +autodoc_mock_imports = [ + "padelpy", + "alvadescpy", + "ecabc", +] + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "torch": ("https://pytorch.org/docs/stable", None), + "numpy": ("https://numpy.org/doc/stable", None), +} + +myst_enable_extensions = [ + "colon_fence", + "deflist", +] + + +def _sanitize_autodoc_docstrings(app, what, name, obj, options, lines): + """Normalize legacy Google-style docstrings for Sphinx warning-as-error builds.""" + cleaned: list[str] = [] + for line in lines: + text = line.replace("**kwargs", "kwargs") + text = text.replace("(*, 1)", "(N, 1)") + if "$$" in text: + text = text.replace("$$", "") + cleaned.append(text) + # Keep summary + Args/Returns, but drop bracketed kwargs catalogs that break RST. + out: list[str] = [] + skipping_catalog = False + for line in cleaned: + stripped = line.strip() + if "kwargs can include any in" in stripped: + skipping_catalog = True + out.append("Additional keyword arguments are forwarded to training.") + continue + if skipping_catalog: + if stripped.startswith("Args:") or stripped.startswith("Returns:"): + skipping_catalog = False + else: + continue + out.append(line) + lines[:] = out + + +def setup(app): + app.connect("autodoc-process-docstring", _sanitize_autodoc_docstrings) diff --git a/docs/source/data.rst b/docs/source/data.rst new file mode 100644 index 0000000..4bc29c2 --- /dev/null +++ b/docs/source/data.rst @@ -0,0 +1,143 @@ +Bundled property datasets +========================= + +ECNet ships curated SMILES/target pairs as package data under +``ecnet/datasets/data/``. Load them with the ``ecnet.datasets.load_*`` helpers +(see :doc:`api/datasets`). Units conventions for blend helpers are summarized +in :doc:`units`. + +These cards describe what the package actually distributes. Compound-level +literature provenance is **not** encoded in the bundled ``.smiles`` / +``.target`` files; do not treat compound counts or property labels as a +substitute for primary citations. + +Common attributes +----------------- + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Field + - Value + * - Format + - Parallel UTF-8 text files: one SMILES string per line (``*.smiles``) and + one numeric target per line (``*.target``), same row order and length + * - Package path + - ``src/ecnet/datasets/data/`` (installed as package data) + * - Access + - ``from ecnet.datasets import load_``; optional + ``as_dataset=True`` returns ``QSPRDatasetFromFile`` + * - Default descriptors + - Built at load/train time via PaDEL (``backend='padel'``); alvaDesc is + optional and licensed separately + * - License + - Distributed with ECNet under the project MIT license + * - Limitations + - No silent unit conversion in ``ECNet.fit``; descriptor backends may be + slow or unavailable without Java (PaDEL) or an alvaDesc license + +Property cards +-------------- + +Counts below are the number of lines in the bundled ``.smiles`` files at the +time of documentation (one compound per line). + +Boiling point (``bp``) +~~~~~~~~~~~~~~~~~~~~~~ + +* **Loader:** ``load_bp`` +* **Files:** ``bp.smiles``, ``bp.target`` +* **Size:** 205 compounds +* **Target unit:** °C +* **Scope:** Boiling-point regression targets paired with SMILES + +Cetane number (``cn``) +~~~~~~~~~~~~~~~~~~~~~~ + +* **Loader:** ``load_cn`` +* **Files:** ``cn.smiles``, ``cn.target`` +* **Size:** 460 compounds +* **Target unit:** Cetane number (dimensionless scale as stored) +* **Scope:** Ignition-quality targets for fuel-like structures + +Cloud point (``cp``) +~~~~~~~~~~~~~~~~~~~~ + +* **Loader:** ``load_cp`` +* **Files:** ``cp.smiles``, ``cp.target`` +* **Size:** 43 compounds +* **Target unit:** °C +* **Scope:** Cloud-point targets; blend I/O for ``cloud_point`` is also °C + (:doc:`units`) + +Kinematic viscosity (``kv``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* **Loader:** ``load_kv`` +* **Files:** ``kv.smiles``, ``kv.target`` +* **Size:** 213 compounds +* **Target unit:** mm²/s (cSt) at 313 K +* **Scope:** Viscosity targets for the KV blend helper (cSt) + +Lower heating value (``lhv``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* **Loader:** ``load_lhv`` +* **Files:** ``lhv.smiles``, ``lhv.target`` +* **Size:** 388 compounds +* **Target unit:** MJ/kg (= kJ/g as documented in the loader) +* **Scope:** Heating-value regression targets + +Motor octane number (``mon``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* **Loader:** ``load_mon`` +* **Files:** ``mon.smiles``, ``mon.target`` +* **Size:** 308 compounds +* **Target unit:** MON (dimensionless scale as stored) +* **Scope:** Motor octane targets + +Melting point (``mp``) +~~~~~~~~~~~~~~~~~~~~~~ + +* **Loader:** ``load_mp`` +* **Files:** ``mp.smiles``, ``mp.target`` +* **Size:** 147 compounds +* **Target unit:** °C +* **Scope:** Melting-point regression targets + +Pour point (``pp``) +~~~~~~~~~~~~~~~~~~~ + +* **Loader:** ``load_pp`` +* **Files:** ``pp.smiles``, ``pp.target`` +* **Size:** 41 compounds +* **Target unit:** °C +* **Scope:** Pour-point regression targets (smallest bundled set) + +Research octane number (``ron``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* **Loader:** ``load_ron`` +* **Files:** ``ron.smiles``, ``ron.target`` +* **Size:** 308 compounds +* **Target unit:** RON (dimensionless scale as stored) +* **Scope:** Research octane targets + +Yield sooting index (``ysi``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* **Loader:** ``load_ysi`` +* **Files:** ``ysi.smiles``, ``ysi.target`` +* **Size:** 558 compounds +* **Target unit:** Unified YSI scale as stored +* **Scope:** Sooting-tendency targets + +Repository research archive (``databases/``) +-------------------------------------------- + +The top-level ``databases/`` directory (CSV masters and a filter script) is a +**non-install research archive**. It is excluded from wheels and sdists and is +not required to use the installed package. See ``databases/README.md`` in the +source repository. diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..26740c7 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,30 @@ +ECNet documentation +=================== + +**ECNet** is a Python package for building multilayer-perceptron models that +predict fuel properties from molecular structure using quantitative +structure–property relationship (QSPR) descriptors. It ships bundled property +datasets, PaDEL and alvaDesc descriptor backends, hyperparameter-tuning helpers, +and analytical blend-property equations. + +This site documents the **current v4 API**. The 2017 JOSS article and +``paper/paper.md`` describe a prior project/build/node ensemble architecture; +that workflow is historical and is **not** the public surface documented here. + +.. toctree:: + :maxdepth: 2 + :caption: Contents + + installation + quickstart + units + data + stability + api/index + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/installation.rst b/docs/source/installation.rst new file mode 100644 index 0000000..83882f6 --- /dev/null +++ b/docs/source/installation.rst @@ -0,0 +1,45 @@ +Installation +============ + +Requires **Python 3.11** or newer. Java is required for the default PaDEL +descriptor backend. A licensed alvaDesc installation is required only when +using ``backend='alvadesc'``. The alvaDesc Python wrapper still imports +``pkg_resources``; if that import fails under newer setuptools, either use +PaDEL (``backend='padel'``, the default) or install ``setuptools<82``. + +Install from PyPI +----------------- + +.. code-block:: bash + + pip install ecnet + +Runtime dependencies (``torch``, ``scikit-learn``, ``padelpy``, ``alvadescpy``, +``ecabc``) are installed with the package. For custom PyTorch builds (for +example GPU wheels), install those first, then install ECNet. + +Upgrade +------- + +.. code-block:: bash + + pip install --upgrade ecnet + +Install from source +------------------- + +.. code-block:: bash + + git clone https://github.com/ecrl/ecnet.git + cd ecnet + pip install -e . + +Development extras +------------------ + +.. code-block:: bash + + pip install -e ".[dev]" # tests, ruff, pre-commit, pip-audit + pip install -e ".[docs]" # Sphinx + Furo and related tools + +See the repository ``CONTRIBUTING.md`` for local hooks and checks. diff --git a/docs/source/quickstart.rst b/docs/source/quickstart.rst new file mode 100644 index 0000000..23995a1 --- /dev/null +++ b/docs/source/quickstart.rst @@ -0,0 +1,53 @@ +Quickstart +========== + +The examples below use synthetic descriptor values so they run without calling +PaDEL. For real QSPR workflows, construct a dataset from SMILES (or use a +bundled ``load_*`` helper) with the default ``backend='padel'``. + +Train a small model +------------------- + +.. code-block:: python + + from ecnet import ECNet + from ecnet.datasets import QSPRDatasetFromValues + + desc_vals = [ + [0.0, 0.1, 0.2, 0.3], + [0.1, 0.2, 0.3, 0.4], + [0.2, 0.3, 0.4, 0.5], + [0.3, 0.4, 0.5, 0.6], + ] + target_vals = [[1.0], [2.0], [3.0], [4.0]] + dataset = QSPRDatasetFromValues(desc_vals, target_vals) + + model = ECNet(input_dim=4, output_dim=1, hidden_dim=16, n_hidden=1) + train_loss, valid_loss = model.fit( + dataset=dataset, + epochs=10, + batch_size=2, + random_state=0, + ) + +Save and reload +--------------- + +``ECNet.save`` writes an ``ecnet-state-v1`` checkpoint. ``load_model`` also +reads legacy full-module ``.pt`` pickles. + +.. code-block:: python + + from ecnet.model import load_model + + model.save("example_model.pt") + restored = load_model("example_model.pt") + +Next steps +---------- + +- :doc:`api/index` — autodoc for the frozen public surface +- :doc:`stability` — compatibility and versioning policy +- :doc:`units` — cloud point (°C), kinematic viscosity (cSt), and scales +- Example notebooks under ``examples/`` on GitHub (PaDEL/Java; run manually — + not executed in CI; see ``CONTRIBUTING.md``). diff --git a/docs/source/stability.rst b/docs/source/stability.rst new file mode 100644 index 0000000..10e6981 --- /dev/null +++ b/docs/source/stability.rst @@ -0,0 +1,84 @@ +API stability policy +==================== + +This page records the **frozen public API** for the ``4.1.x`` / ``4.2.x`` +compatibility series and the versioning rules that govern change. It +summarizes design ``docs/design/DESIGN.md`` §8.1 and §8.4. Characterization +tests under ``tests/`` are the executable contract. + +Frozen public surface +--------------------- + +Downstream code may rely on the following import paths and names remaining +available with compatible signatures and defaults: + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Module + - Public names + * - ``ecnet`` + - ``ECNet``, ``__version__`` + * - ``ecnet.model`` + - ``load_model`` + * - ``ecnet.datasets`` + - ``load_bp``, ``load_cn``, ``load_cp``, ``load_kv``, ``load_lhv``, + ``load_mon``, ``load_mp``, ``load_pp``, ``load_ron``, ``load_ysi``, + ``QSPRDataset``, ``QSPRDatasetFromFile``, ``QSPRDatasetFromValues`` + * - ``ecnet.tasks`` + - ``select_rfr``, ``tune_batch_size``, ``tune_model_architecture``, + ``tune_training_parameters`` + * - ``ecnet.blends`` + - ``cetane_number``, ``yield_sooting_index``, ``kinematic_viscosity``, + ``cloud_point``, ``lower_heating_value``, ``linear_blend_err``, + ``exponential_blend_err``, ``kv_error`` + * - ``ecnet.callbacks`` + - ``LRDecayLinear``, ``Validator``, ``Callback``, ``CallbackOperator`` + +Advanced import: ``PCADataset`` +------------------------------- + +``PCADataset`` lives in ``ecnet.datasets.structs`` and is **not** part of the +``ecnet.datasets`` public export surface for this compatibility series. Use: + +.. code-block:: python + + from ecnet.datasets.structs import PCADataset + +Additive optional keyword arguments on frozen callables are allowed when +defaults preserve current behavior. + +Versioning policy +----------------- + +.. list-table:: + :header-rows: 1 + :widths: 20 35 45 + + * - Release class + - Examples + - Contract + * - Patch (``4.1.x``) + - Bugfixes, packaging, tests, documentation corrections + - Signature and numeric oracles must continue to match + * - Minor (``4.2.0``) + - Internal hardening, Sphinx/governance completion, dependency range + expansions proven on CI + - Still API-compatible with the frozen surface + * - Major (``5.0.0``) + - Intentional breaks only with explicit approval + - Examples: removing pickle-based full-module ``torch.load``, changing + default descriptor backends, altering bundled dataset schemas + +Executable contract +------------------- + +CI and local verification should keep these green: + +* Signature locks — ``tests/test_api_signatures.py`` +* Blend oracles — ``tests/blends/``, ``tests/fixtures/`` +* Dataset / model / tasks / callbacks characterization suites + +Coverage floors: ``ecnet.blends`` ≥95% line coverage; overall package ≥90%, +enforced in CI with ``--cov-fail-under=90``. diff --git a/docs/source/units.rst b/docs/source/units.rst new file mode 100644 index 0000000..41798c7 --- /dev/null +++ b/docs/source/units.rst @@ -0,0 +1,36 @@ +Units and numeric conventions +============================= + +Canonical units for the public API (design §8.3). ``ECNet.fit`` does **not** +perform silent unit conversion on model targets; values are used as supplied +by the caller or by bundled ``.target`` files. + +Blend properties +---------------- + +.. list-table:: + :header-rows: 1 + :widths: 30 25 45 + + * - Quantity + - Canonical unit + - Notes + * - Cloud point (blend I/O) + - °C + - Internal Rankine conversion remains an implementation detail; public + inputs and outputs stay in °C. + * - Kinematic viscosity + - cSt + - Mixing rule follows Ding et al. as implemented in + ``ecnet.blends.kinematic_viscosity``. + * - Cetane number, YSI, LHV, octane numbers (RON/MON) + - Dimensionless property scales + - Same scales as the bundled target files; see :doc:`data` for per-property + cards and known provenance limits. + +Model targets +------------- + +Regression targets for ``ECNet`` follow the units of the supplied dataset. +Bundled loaders return the scales stored in package data without converting +temperature, viscosity, or other engineering units inside the model. diff --git a/ecnet/__init__.py b/ecnet/__init__.py deleted file mode 100644 index 7d8f8d4..0000000 --- a/ecnet/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import pkg_resources -from .model import ECNet - -__version__ = pkg_resources.get_distribution("ecnet").version diff --git a/ecnet/blends/__init__.py b/ecnet/blends/__init__.py deleted file mode 100644 index 5e6306e..0000000 --- a/ecnet/blends/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .predict import cetane_number, yield_sooting_index, kinematic_viscosity, cloud_point,\ - lower_heating_value -from .equations import linear_blend_err, exponential_blend_err, kv_error diff --git a/ecnet/datasets/__init__.py b/ecnet/datasets/__init__.py deleted file mode 100644 index 8705738..0000000 --- a/ecnet/datasets/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .load_data import load_bp, load_cn, load_cp, load_kv, load_lhv, load_mon,\ - load_pp, load_ron, load_ysi, load_mp -from .structs import QSPRDataset, QSPRDatasetFromFile, QSPRDatasetFromValues diff --git a/ecnet/datasets/data/.DS_Store b/ecnet/datasets/data/.DS_Store deleted file mode 100644 index 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 List[str]: - """ - Args: - smiles_fn (str): filename/path for SMILES file - - Returns: - list[str]: [smiles_0, ..., smiles_N] - """ - - with open(smiles_fn, 'r') as smi_file: - smiles = smi_file.readlines() - smi_file.close() - smiles = [s.replace('\n', '') for s in smiles] - return smiles - - -def _open_target_file(target_fn: str) -> List[List[float]]: - """ - Args: - target_fn (str): filename/path for target values file - - Returns: - list[list[float]]: lists of target values, in preparation for torch.tensor of shape - (n_targets, 1) - """ - - with open(target_fn, 'r') as tar_file: - target = tar_file.readlines() - tar_file.close() - target = [[float(t.replace('\n', ''))] for t in target] - return target - - -def _get_prop_paths(prop: str) -> Tuple[str, str]: - """ - Args: - prop (str): any in ['bp', 'cn', 'cp', 'kv', 'lhv', 'mon', 'pp', 'ron', 'ysi', 'mp'] - - Returns: - tuple[str, str]: (path to smiles file (str), path to targets file (str)) - """ - - return ( - path.join(_DATA_PATH, '{}.smiles'.format(prop)), - path.join(_DATA_PATH, '{}.target'.format(prop)) - ) - - -def _get_file_data(prop: str) -> Tuple[List[str], List[List[float]]]: - """ - Args: - prop (str): any in ['bp', 'cn', 'cp', 'kv', 'lhv', 'mon', 'pp', 'ron', 'ysi', 'mp'] - - Returns: - tuple[list[str], list[list[float]]]: (smiles, targets) - """ - - fn_smiles, fn_target = _get_prop_paths(prop) - smiles = _open_smiles_file(fn_smiles) - target = _open_target_file(fn_target) - return (smiles, target) - - -def _load_set(prop: str, backend: str) -> QSPRDatasetFromFile: - """ - Args: - prop (str): any in ['bp', 'cn', 'cp', 'kv', 'lhv', 'mon', 'pp', 'ron', 'ysi', 'mp'] - - Returns: - QSPRDatasetFromFile: loaded set - """ - - fn_smiles, fn_target = _get_prop_paths(prop) - target_vals = _open_target_file(fn_target) - return QSPRDatasetFromFile(fn_smiles, target_vals, backend) - - -def load_bp(as_dataset: bool = False, backend: str = 'padel') -> Union[ - Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: - """ - Loads boiling point data; target values given in Celsius - - Args: - as_dataset (bool, optional): if True, return QSPRDatasetFromFile object housing data; - otherwise, return tuple of smiles and target values - backend (str, optional): any in ['padel', 'alvadesc'] - - Returns: - Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: either tuple of (smiles, - target vals) or QSPRDatasetFromFile - """ - - if not as_dataset: - return _get_file_data('bp') - return _load_set('bp', backend) - - -def load_cn(as_dataset: bool = False, backend: str = 'padel') -> Union[ - Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: - """ - Loads cetane number data; target values given in CN units - - Args: - as_dataset (bool, optional): if True, return QSPRDatasetFromFile object housing data; - otherwise, return tuple of smiles and target values - backend (str, optional): any in ['padel', 'alvadesc'] - - Returns: - Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: either tuple of (smiles, - target vals) or QSPRDatasetFromFile - """ - - if not as_dataset: - return _get_file_data('cn') - return _load_set('cn', backend) - - -def load_cp(as_dataset: bool = False, backend: str = 'padel') -> Union[ - Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: - """ - Loads cloud point data; target values given in Celsius - - Args: - as_dataset (bool, optional): if True, return QSPRDatasetFromFile object housing data; - otherwise, return tuple of smiles and target values - backend (str, optional): any in ['padel', 'alvadesc'] - - Returns: - Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: either tuple of (smiles, - target vals) or QSPRDatasetFromFile - """ - - if not as_dataset: - return _get_file_data('cp') - return _load_set('cp', backend) - - -def load_kv(as_dataset: bool = False, backend: str = 'padel') -> Union[ - Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: - """ - Loads kinematic viscosity data; target values given in mm^2/s (cSt) at 313 deg. K - - Args: - as_dataset (bool, optional): if True, return QSPRDatasetFromFile object housing data; - otherwise, return tuple of smiles and target values - backend (str, optional): any in ['padel', 'alvadesc'] - - Returns: - Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: either tuple of (smiles, - target vals) or QSPRDatasetFromFile - """ - - if not as_dataset: - return _get_file_data('kv') - return _load_set('kv', backend) - - -def load_lhv(as_dataset: bool = False, backend: str = 'padel') -> Union[ - Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: - """ - Loads lower heating value data; target values given in MJ/kg = kJ/g - - Args: - as_dataset (bool, optional): if True, return QSPRDatasetFromFile object housing data; - otherwise, return tuple of smiles and target values - backend (str, optional): any in ['padel', 'alvadesc'] - - Returns: - Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: either tuple of (smiles, - target vals) or QSPRDatasetFromFile - """ - - if not as_dataset: - return _get_file_data('lhv') - return _load_set('lhv', backend) - - -def load_mon(as_dataset: bool = False, backend: str = 'padel') -> Union[ - Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: - """ - Loads motor octane number data; target values given in MON units - - Args: - as_dataset (bool, optional): if True, return QSPRDatasetFromFile object housing data; - otherwise, return tuple of smiles and target values - backend (str, optional): any in ['padel', 'alvadesc'] - - Returns: - Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: either tuple of (smiles, - target vals) or QSPRDatasetFromFile - """ - - if not as_dataset: - return _get_file_data('mon') - return _load_set('mon', backend) - - -def load_mp(as_dataset: bool = False, backend: str = 'padel') -> Union[ - Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: - """ - Loads melting point data; target values given in Celsius - - Args: - as_dataset (bool, optional): if True, return QSPRDatasetFromFile object housing data; - otherwise, return tuple of smiles and target values - backend (str, optional): any in ['padel', 'alvadesc'] - - Returns: - Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: either tuple of (smiles, - target vals) or QSPRDatasetFromFile - """ - - if not as_dataset: - return _get_file_data('mp') - return _load_set('mp', backend) - - -def load_pp(as_dataset: bool = False, backend: str = 'padel') -> Union[ - Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: - """ - Loads pour point data; target values given in Celsius - - Args: - as_dataset (bool, optional): if True, return QSPRDatasetFromFile object housing data; - otherwise, return tuple of smiles and target values - backend (str, optional): any in ['padel', 'alvadesc'] - - Returns: - Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: either tuple of (smiles, - target vals) or QSPRDatasetFromFile - """ - - if not as_dataset: - return _get_file_data('pp') - return _load_set('pp', backend) - - -def load_ron(as_dataset: bool = False, backend: str = 'padel') -> Union[ - Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: - """ - Loads research octane number data; target values given in RON units - - Args: - as_dataset (bool, optional): if True, return QSPRDatasetFromFile object housing data; - otherwise, return tuple of smiles and target values - backend (str, optional): any in ['padel', 'alvadesc'] - - Returns: - Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: either tuple of (smiles, - target vals) or QSPRDatasetFromFile - """ - - if not as_dataset: - return _get_file_data('ron') - return _load_set('ron', backend) - - -def load_ysi(as_dataset: bool = False, backend: str = 'padel') -> Union[ - Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: - """ - Loads yield sooting index data; target values given in unified YSI units - - Args: - as_dataset (bool, optional): if True, return QSPRDatasetFromFile object housing data; - otherwise, return tuple of smiles and target values - backend (str, optional): any in ['padel', 'alvadesc'] - - Returns: - Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: either tuple of (smiles, - target vals) or QSPRDatasetFromFile - """ - - if not as_dataset: - return _get_file_data('ysi') - return _load_set('ysi', backend) diff --git a/ecnet/tasks/__init__.py b/ecnet/tasks/__init__.py deleted file mode 100644 index de6541f..0000000 --- a/ecnet/tasks/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .feature_selection import select_rfr -from .parameter_tuning import N_TESTS, CONFIG, tune_batch_size, tune_model_architecture,\ - tune_training_parameters diff --git a/ecnet/tasks/feature_selection.py b/ecnet/tasks/feature_selection.py deleted file mode 100644 index a291b1b..0000000 --- a/ecnet/tasks/feature_selection.py +++ /dev/null @@ -1,40 +0,0 @@ -r"""Feature selection functions""" -from typing import List, Tuple -from sklearn.ensemble import RandomForestRegressor - -from ..datasets.structs import QSPRDataset - - -def select_rfr(dataset: QSPRDataset, total_importance: float = 0.95, - **kwargs) -> Tuple[List[int], List[float]]: - """ - select_rfr: reduces input data dimensionality such that specified proportion of total feature - importance (derived from random forest regression) is retained in feature subset - - Args: - dataset (QSPRDataset): input data - total_importance (float): total feature importance to retain - **kwargs: additional arguments passed to sklearn.ensemble.RandomForestRegressor - - Returns: - tuple[list[int], list[float]]: (selected feature indices, selected feature importances) - """ - - X = dataset.desc_vals - y = [dv[0] for dv in dataset.target_vals] - regr = RandomForestRegressor(**kwargs) - regr.fit(X, y) - importances = sorted( - [(regr.feature_importances_[i], i) - for i in range(len(dataset.desc_vals[0]))], - key=lambda x: x[0], reverse=True - ) - tot_imp = 0.0 - for idx, i in enumerate(importances): - tot_imp += i[0] - idx_cutoff = idx - if tot_imp >= total_importance: - break - desc_imp = [i[0] for i in importances][:idx_cutoff] - desc_idx = [i[1] for i in importances][:idx_cutoff] - return (desc_idx, desc_imp) diff --git a/ecnet/tasks/parameter_tuning.py b/ecnet/tasks/parameter_tuning.py deleted file mode 100644 index 266c981..0000000 --- a/ecnet/tasks/parameter_tuning.py +++ /dev/null @@ -1,303 +0,0 @@ -from ecabc import ABC -from sklearn.metrics import median_absolute_error -from copy import deepcopy -import numpy as np - -from ..model import ECNet -from ..datasets.structs import QSPRDataset - -from typing import Iterable - -N_TESTS = 10 - -CONFIG = { - 'training_params_range': { - 'lr': (1e-16, 0.05), - 'lr_decay': (1e-16, 0.0001) - }, - 'architecture_params_range': { - 'hidden_dim': (1, 1024), - 'n_hidden': (1, 5), - 'dropout': (0.0, 0.1) - } -} - - -def _get_kwargs(**kwargs): - """ - Returns dictionary of relevant training parameters from **kwargs - - Args: - **kwargs: key word arguments - - Returns: - dict: relevant relevant kwargs, else default values - """ - - return { - 'model': kwargs.get('model'), - 'train_ds': kwargs.get('train_ds'), - 'eval_ds': kwargs.get('eval_ds'), - 'epochs': kwargs.get('epochs', 100), - 'batch_size': kwargs.get('batch_size', 32), - 'valid_size': kwargs.get('valid_size', 0.2), - 'patience': kwargs.get('patience', 32), - 'lr_decay': kwargs.get('lr_decay', 0.0), - 'lr': kwargs.get('lr', 0.001), - 'beta_1': kwargs.get('beta_1', 0.9), - 'beta_2': kwargs.get('beta_2', 0.999), - 'eps': kwargs.get('eps', 1e-08), - 'weight_decay': kwargs.get('weight_decay', 0.0), - 'hidden_dim': kwargs.get('hidden_dim', 128), - 'n_hidden': kwargs.get('n_hidden', 2), - 'dropout': kwargs.get('dropout', 0.0), - 'amsgrad': kwargs.get('amsgrad', False) - } - - -def _evaluate_model(trial_spec: dict) -> float: - """ - Training sub-function for cost functions _cost_batch_size, _cost_arch, _cost_train_hp; - Each model configuration is tested ecnet.tasks.parameter_tuning.N_TESTS times, average - median absolute error across all tests returned; default 10 tests per configuration - - Args: - trial_spec (dict): all relevant parameters for this training trial - - Returns: - float: median absolute error for dataset being evaluated (trial_spec['eval_ds']) - """ - - model = ECNet( - trial_spec['train_ds'].desc_vals.shape[1], - trial_spec['train_ds'].target_vals.shape[1], - trial_spec['hidden_dim'], - trial_spec['n_hidden'], - trial_spec['dropout'] - ) - maes = [] - for _ in range(N_TESTS): - model._construct() - model.fit( - dataset=trial_spec['train_ds'], - epochs=trial_spec['epochs'], - batch_size=trial_spec['batch_size'], - patience=trial_spec['patience'], - lr_decay=trial_spec['lr_decay'], - lr=trial_spec['lr_decay'], - betas=(trial_spec['beta_1'], trial_spec['beta_2']), - eps=trial_spec['eps'], - weight_decay=trial_spec['weight_decay'], - amsgrad=trial_spec['amsgrad'] - ) - yhat_eval = model(trial_spec['eval_ds'].desc_vals).detach().numpy() - y_eval = trial_spec['eval_ds'].target_vals - maes.append(median_absolute_error(y_eval, yhat_eval)) - return np.mean(maes) - - -def _cost_batch_size(vals: Iterable[float], **kwargs) -> float: - """ - Cost function for tuning batch size - - Args: - vals (iterable[float]): values passed to cost function from ABC; just contains batch size - **kwargs: user-defined training arguments, datasets to be passed to _evaluate_model - - Returns: - float: median absolute error for dataset being evaluated (**kwarg: eval_ds) - """ - - trial_spec = _get_kwargs(**kwargs) - trial_spec['batch_size'] = vals[0] - return _evaluate_model(trial_spec) - - -def tune_batch_size(n_bees: int, n_iter: int, dataset_train: QSPRDataset, - dataset_eval: QSPRDataset, n_processes: int = 1, - **kwargs) -> dict: - """ - Tunes the batch size during training; additional **kwargs can include any in: - [ - # ECNet parameters - 'epochs' (default 100), - 'valid_size' (default 0.2), - 'patience' (default 32), - 'lr_decay' (default 0.0), - 'hidden_dim' (default 128), - 'n_hidden' (default 2), - 'dropout': (default 0.0), - # Adam optim. alg. arguments - 'lr' (default 0.001), - 'beta_1' (default 0.9), - 'beta_2' (default 0.999), - 'eps' (default 1e-8), - 'weight_decay' (default 0.0), - 'amsgrad' (default False) - ] - - Args: - n_bees (int): number of employer bees to use in ABC algorithm - n_iter (int): number of iterations, or "search cycles", for ABC algorithm - dataset_train (QSPRDataset): dataset used to train evaluation models - dataset_eval (QSPRDataset): dataset used for evaluation - n_processes (int, optional): if > 1, uses multiprocessing when evaluating at an iteration - **kwargs: additional arguments - - Returns: - dict: {'batch_size': int} - """ - - kwargs['train_ds'] = dataset_train - kwargs['eval_ds'] = dataset_eval - abc = ABC(n_bees, _cost_batch_size, num_processes=n_processes, obj_fn_args=kwargs) - abc.add_param(1, len(kwargs.get('train_ds').desc_vals), name='batch_size') - abc.initialize() - for _ in range(n_iter): - abc.search() - return {'batch_size': abc.best_params['batch_size']} - - -def _cost_arch(vals, **kwargs): - """ - Cost function for tuning NN architecture - - Args: - vals (iterable[float]): values passed to cost function from ABC; contains: - - hidden_dim - - n_nidden - - dropout - **kwargs: user-defined training arguments, datasets to be passed to _evaluate_model - - Returns: - float: median absolute error for dataset being evaluated (**kwarg: eval_ds) - """ - - trial_spec = _get_kwargs(**kwargs) - trial_spec['hidden_dim'] = vals[0] - trial_spec['n_hidden'] = vals[1] - trial_spec['dropout'] = vals[2] - return _evaluate_model(trial_spec) - - -def tune_model_architecture(n_bees: int, n_iter: int, dataset_train: QSPRDataset, - dataset_eval: QSPRDataset, n_processes: int = 1, - **kwargs) -> dict: - """ - Tunes model architecture parameters (number of hidden layers, neurons per hidden layer, neuron - dropout); additional **kwargs can include any in: - [ - # ECNet parameters - 'epochs' (default 100), - 'batch_size' (default 32), - 'valid_size' (default 0.2), - 'patience' (default 32), - 'lr_decay' (default 0.0), - # Adam optim. alg. arguments - 'lr' (default 0.001), - 'beta_1' (default 0.9), - 'beta_2' (default 0.999), - 'eps' (default 1e-8), - 'weight_decay' (default 0.0), - 'amsgrad' (default False) - ] - - Args: - n_bees (int): number of employer bees to use in ABC algorithm - n_iter (int): number of iterations, or "search cycles", for ABC algorithm - dataset_train (QSPRDataset): dataset used to train evaluation models - dataset_eval (QSPRDataset): dataset used for evaluation - n_processes (int, optional): if > 1, uses multiprocessing when evaluating at an iteration - **kwargs: additional arguments - - Returns: - dict: {'hidden_dim': int, 'n_hidden': int, 'dropout': float} - """ - - kwargs['train_ds'] = dataset_train - kwargs['eval_ds'] = dataset_eval - abc = ABC(n_bees, _cost_arch, num_processes=n_processes, obj_fn_args=kwargs) - abc.add_param(CONFIG['architecture_params_range']['hidden_dim'][0], - CONFIG['architecture_params_range']['hidden_dim'][1], name='hidden_dim') - abc.add_param(CONFIG['architecture_params_range']['n_hidden'][0], - CONFIG['architecture_params_range']['n_hidden'][1], name='n_hidden') - abc.add_param(CONFIG['architecture_params_range']['dropout'][0], - CONFIG['architecture_params_range']['dropout'][1], name='dropout') - abc.initialize() - for _ in range(n_iter): - abc.search() - return { - 'hidden_dim': abc.best_params['hidden_dim'], - 'n_hidden': abc.best_params['n_hidden'], - 'dropout': abc.best_params['dropout'] - } - - -def _cost_train_hp(vals, **kwargs): - """ - Cost function for tuning NN training parameters (Adam optim. hyper-parameters) - - Args: - vals (iterable[float]): values passed to cost function from ABC; contains: - - lr (learning rate) - - lr_decay (learning rate decay) - **kwargs: user-defined training arguments, datasets to be passed to _evaluate_model - - Returns: - float: median absolute error for dataset being evaluated (**kwarg: eval_ds) - """ - - trial_spec = _get_kwargs(**kwargs) - trial_spec['lr'] = vals[0] - trial_spec['lr_decay'] = vals[1] - return _evaluate_model(trial_spec) - - -def tune_training_parameters(n_bees: int, n_iter: int, dataset_train: QSPRDataset, - dataset_eval: QSPRDataset, n_processes: int = 1, - **kwargs) -> dict: - """ - Tunes learning rate, learning rate decay; additional **kwargs can include any in: - [ - # ECNet parameters - 'epochs' (default 100), - 'batch_size' (default 32), - 'valid_size' (default 0.2), - 'patience' (default 32), - 'hidden_dim' (default 128), - 'n_hidden' (default 2), - 'dropout': (default 0.0), - # Adam optim. alg. arguments - 'beta_1' (default 0.9), - 'beta_2' (default 0.999), - 'eps' (default 1e-8), - 'weight_decay' (default 0.0), - 'amsgrad' (default False) - ] - - Args: - n_bees (int): number of employer bees to use in ABC algorithm - n_iter (int): number of iterations, or "search cycles", for ABC algorithm - dataset_train (QSPRDataset): dataset used to train evaluation models - dataset_eval (QSPRDataset): dataset used for evaluation - n_processes (int, optional): if > 1, uses multiprocessing when evaluating at an iteration - **kwargs: additional arguments - - Returns: - dict: {'lr': float, 'lr_decay': float} - """ - - kwargs['train_ds'] = dataset_train - kwargs['eval_ds'] = dataset_eval - abc = ABC(n_bees, _cost_train_hp, num_processes=n_processes, obj_fn_args=kwargs) - abc.add_param(CONFIG['training_params_range']['lr'][0], - CONFIG['training_params_range']['lr'][1], name='lr') - abc.add_param(CONFIG['training_params_range']['lr_decay'][0], - CONFIG['training_params_range']['lr_decay'][1], name='lr_decay') - abc.initialize() - for _ in range(n_iter): - abc.search() - return { - 'lr': abc.best_params['lr'], - 'lr_decay': abc.best_params['lr_decay'] - } diff --git a/examples/example.ipynb b/examples/example.ipynb index e7c550d..5024a77 100644 --- a/examples/example.ipynb +++ b/examples/example.ipynb @@ -1,63 +1,105 @@ { "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Cetane number example (v4)\n", + "\n", + "End-to-end cetane-number (CN) regression with the current public API and the default **PaDEL** backend:\n", + "`load_cn` → train/test split → `select_rfr` → descriptor scaling → `ECNet.fit` → metrics / parity plot → multi-seed smoke.\n", + "\n", + "This notebook is a demo-scale walkthrough (short epochs, small architecture). PaDEL CN descriptors span many orders of magnitude, so **training-set standardization** is included before fitting — without it, short runs often fail to learn.\n", + "\n", + "**Requirements:** Python 3.11+, ECNet installed, Java for PaDEL. For a licensed alvaDesc install, you may select that backend instead.\n", + "Commit notebooks **without** stored outputs." + ] + }, + { + "cell_type": "markdown", + "id": "a92941f3", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Pin BLAS/OpenMP to one thread **before** NumPy/scikit-learn import so `select_rfr` is stable, then seed Python / NumPy / PyTorch. Suppress library warnings so cell output stays results-only.\n", + "\n", + "`SEED` stabilizes splits, RF selection, weight init, and training **given the descriptor matrix from this session’s single `load_cn` call**. Restart & Run All is the reproducibility unit: do not reload PaDEL mid-notebook. Fresh Java/PaDEL recomputes can differ slightly across machines or kernel restarts even with the same `SEED`.\n" + ] + }, { "cell_type": "code", - "execution_count": 1, - "id": "accomplished-article", + "execution_count": null, + "id": "ca6f91ff", "metadata": {}, "outputs": [], "source": [ - "from ecnet.datasets import load_cn\n", - "from ecnet.tasks.feature_selection import select_rfr\n", - "from ecnet.tasks.parameter_tuning import tune_batch_size, tune_model_architecture,\\\n", - " tune_training_parameters\n", - "from ecnet import ECNet\n", - "from sklearn.model_selection import train_test_split\n", - "from sklearn.decomposition import PCA\n", - "from sklearn.metrics import median_absolute_error, r2_score\n", - "import torch\n", + "import os\n", + "\n", + "os.environ[\"OMP_NUM_THREADS\"] = \"1\"\n", + "os.environ[\"MKL_NUM_THREADS\"] = \"1\"\n", + "os.environ[\"OPENBLAS_NUM_THREADS\"] = \"1\"\n", + "\n", + "import random\n", + "import warnings\n", + "from copy import deepcopy\n", + "from math import sqrt\n", + "\n", "import numpy as np\n", + "import torch\n", "from matplotlib import pyplot as plt\n", - "from copy import deepcopy\n", - "from math import sqrt" + "from sklearn.metrics import median_absolute_error, r2_score\n", + "from sklearn.model_selection import train_test_split\n", + "\n", + "from ecnet import ECNet\n", + "from ecnet.datasets import load_cn\n", + "from ecnet.tasks.feature_selection import select_rfr\n", + "\n", + "warnings.filterwarnings(\"ignore\")\n", + "torch.set_num_threads(1)\n", + "\n", + "SEED = 42\n", + "random.seed(SEED)\n", + "np.random.seed(SEED)\n", + "torch.manual_seed(SEED)\n", + "\n", + "dataset = load_cn(as_dataset=True, backend=\"padel\")\n", + "print(type(dataset).__name__, dataset.desc_vals.shape, dataset.target_vals.shape)" ] }, { - "cell_type": "code", - "execution_count": 2, - "id": "cloudy-israel", + "cell_type": "markdown", + "id": "cb36bd54", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " torch.Size([460, 5305]) torch.Size([460, 1])\n" - ] - } - ], "source": [ - "dataset = load_cn(as_dataset=True, backend='alvadesc')\n", - "print(type(dataset), dataset.desc_vals.shape, dataset.target_vals.shape)" + "## Motivation\n", + "\n", + "Cetane number indexes diesel ignition quality. Prior ECNet work used structure-based neural nets for CN of biofuel candidates [Kessler et al., *Fuel* (2017)](https://doi.org/10.1016/j.fuel.2017.06.015); the package itself is described in [Kessler & Mack, JOSS (2017)](https://doi.org/10.21105/joss.00401). This notebook shows the current v4 path: PaDEL descriptors [Yap, *J. Comput. Chem.* (2011)](https://doi.org/10.1002/jcc.21707), random-forest importance ranking [Breiman, *Machine Learning* (2001)](https://doi.org/10.1023/A:1010933404324), then a short MLP fit.\n" + ] + }, + { + "cell_type": "markdown", + "id": "d8f8f366", + "metadata": {}, + "source": [ + "## Train / test split\n", + "\n", + "Hold out 20% of molecules for testing. Feature selection and scaling statistics use **only** the training subset." ] }, { "cell_type": "code", - "execution_count": 3, - "id": "ongoing-mortality", + "execution_count": null, + "id": "59f67db7", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "torch.Size([368, 5305]) torch.Size([92, 5305])\n" - ] - } - ], + "outputs": [], "source": [ - "index_train, index_test = train_test_split([i for i in range(len(dataset))],\n", - " test_size=0.2, random_state=42)\n", + "index_train, index_test = train_test_split(\n", + " [i for i in range(len(dataset))],\n", + " test_size=0.2,\n", + " random_state=SEED,\n", + ")\n", + "\n", "dataset_train = deepcopy(dataset)\n", "dataset_train.set_index(index_train)\n", "dataset_test = deepcopy(dataset)\n", @@ -65,232 +107,263 @@ "print(dataset_train.desc_vals.shape, dataset_test.desc_vals.shape)" ] }, + { + "cell_type": "markdown", + "id": "96157660", + "metadata": {}, + "source": [ + "## Feature selection\n", + "\n", + "`select_rfr` keeps the smallest descriptor prefix whose cumulative importance reaches 0.95. Expect a steep early rise, then a flattening approach to the dashed target line." + ] + }, { "cell_type": "code", - "execution_count": 4, - "id": "hybrid-fraud", + "execution_count": null, + "id": "5a531610", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "torch.Size([368, 326]) torch.Size([92, 326])\n", - "['SpMaxA_EA(ed)', 'CIC1', 'S3K', 'SssCH2', 'PHI'] 326\n" - ] - } - ], + "outputs": [], "source": [ - "desc_idx, desc_imp = select_rfr(dataset_train, total_importance=0.95,\n", - " n_estimators=100, n_jobs=4)\n", + "desc_idx, desc_imp = select_rfr(\n", + " dataset_train,\n", + " total_importance=0.95,\n", + " n_estimators=100,\n", + " n_jobs=1,\n", + " random_state=SEED,\n", + ")\n", "dataset_train.set_desc_index(desc_idx)\n", "dataset_test.set_desc_index(desc_idx)\n", "desc_names = [dataset.desc_names[i] for i in desc_idx]\n", "print(dataset_train.desc_vals.shape, dataset_test.desc_vals.shape)\n", - "print(desc_names[:5], len(desc_names))" + "print(desc_names[:5], len(desc_names))\n", + "\n", + "plt.clf()\n", + "plt.xlabel(\"N top descriptors\")\n", + "plt.ylabel(\"Cumulative importance\")\n", + "cum = [sum(desc_imp[: i + 1]) for i in range(len(desc_imp))]\n", + "plt.plot(range(1, len(cum) + 1), cum, color=\"blue\")\n", + "plt.axhline(0.95, color=\"gray\", linestyle=\"--\", linewidth=1)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "fdc55408", + "metadata": {}, + "source": [ + "## Descriptor scaling\n", + "\n", + "PaDEL columns for this set can reach absolute values of $10^7$. Standardize with the **training** mean and standard deviation, then apply the same transform to the test set (never fit scalers on test data)." ] }, { "cell_type": "code", - "execution_count": 5, - "id": "macro-basement", + "execution_count": null, + "id": "2c94e519", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Epoch: 0 | Train loss: 1431.029512781675 | Valid loss: 9223372036854775807\n", - "Epoch: 5 | Train loss: 256.55278923397975 | Valid loss: 312.9687194824219\n", - "Epoch: 10 | Train loss: 182.27286529541016 | Valid loss: 230.17494201660156\n", - "Epoch: 15 | Train loss: 153.28179630616896 | Valid loss: 207.3500518798828\n", - "Epoch: 20 | Train loss: 159.1366594794656 | Valid loss: 207.48974609375\n", - "Epoch: 25 | Train loss: 140.81340639283056 | Valid loss: 239.57949829101562\n", - "Epoch: 30 | Train loss: 98.12742137260177 | Valid loss: 148.23655700683594\n", - "Epoch: 35 | Train loss: 153.3734633517103 | Valid loss: 200.86534118652344\n", - "Epoch: 40 | Train loss: 109.15588352955929 | Valid loss: 141.3617706298828\n", - "Epoch: 45 | Train loss: 88.7531884122057 | Valid loss: 135.99325561523438\n", - "Epoch: 50 | Train loss: 95.22778156825474 | Valid loss: 199.7128143310547\n", - "Epoch: 55 | Train loss: 95.3749063712399 | Valid loss: 131.777099609375\n", - "Epoch: 60 | Train loss: 76.55440091762414 | Valid loss: 117.02298736572266\n", - "Epoch: 65 | Train loss: 69.95369306551356 | Valid loss: 120.0013198852539\n", - "Epoch: 70 | Train loss: 59.19819425725613 | Valid loss: 103.91657257080078\n", - "Epoch: 75 | Train loss: 85.16970467080876 | Valid loss: 107.87355041503906\n", - "Epoch: 80 | Train loss: 58.32269123622349 | Valid loss: 111.8017349243164\n", - "Epoch: 85 | Train loss: 61.898352344019884 | Valid loss: 138.05780029296875\n", - "Epoch: 90 | Train loss: 59.89323991009978 | Valid loss: 120.66303253173828\n", - "Epoch: 95 | Train loss: 48.192738643308886 | Valid loss: 106.33235931396484\n", - "Epoch: 100 | Train loss: 55.81611524309431 | Valid loss: 121.11176300048828\n", - "Epoch: 105 | Train loss: 52.97307249315742 | Valid loss: 104.79387664794922\n", - "Epoch: 110 | Train loss: 67.76363598570532 | Valid loss: 108.07382202148438\n", - "Epoch: 115 | Train loss: 66.85969994992627 | Valid loss: 133.162841796875\n", - "Epoch: 120 | Train loss: 42.65270303220165 | Valid loss: 97.49714660644531\n", - "Epoch: 125 | Train loss: 45.72234181644154 | Valid loss: 113.35257720947266\n", - "Epoch: 130 | Train loss: 46.83465768204255 | Valid loss: 94.48330688476562\n", - "Epoch: 135 | Train loss: 43.752784391649726 | Valid loss: 118.69684600830078\n", - "Epoch: 140 | Train loss: 35.358051896906225 | Valid loss: 97.295166015625\n", - "Epoch: 145 | Train loss: 53.58933078999422 | Valid loss: 129.30355834960938\n", - "Epoch: 150 | Train loss: 40.554945770575074 | Valid loss: 104.6352767944336\n", - "Epoch: 155 | Train loss: 35.993613930786545 | Valid loss: 105.04428100585938\n", - "Epoch: 160 | Train loss: 60.37953196415285 | Valid loss: 120.87259674072266\n", - "Epoch: 165 | Train loss: 38.273053928297394 | Valid loss: 110.79714965820312\n", - "Epoch: 170 | Train loss: 46.848397053828855 | Valid loss: 103.561767578125\n", - "Epoch: 175 | Train loss: 36.76983536830565 | Valid loss: 113.00218963623047\n", - "Epoch: 180 | Train loss: 28.529781373990637 | Valid loss: 100.6767349243164\n", - "Epoch: 185 | Train loss: 43.24229708820784 | Valid loss: 107.31328582763672\n", - "Epoch: 190 | Train loss: 27.72144299461728 | Valid loss: 103.23924255371094\n", - "Epoch: 195 | Train loss: 28.648454134156104 | Valid loss: 115.97016143798828\n", - "Epoch: 200 | Train loss: 26.045289526180344 | Valid loss: 115.27000427246094\n", - "Epoch: 205 | Train loss: 25.658692664840594 | Valid loss: 104.42704772949219\n", - "Epoch: 210 | Train loss: 39.044153979035464 | Valid loss: 144.37440490722656\n", - "Epoch: 215 | Train loss: 23.528638100137517 | Valid loss: 103.07978820800781\n", - "Epoch: 220 | Train loss: 29.17349538997728 | Valid loss: 104.57456970214844\n", - "Epoch: 225 | Train loss: 75.41639725042849 | Valid loss: 113.8974609375\n" - ] - } - ], + "outputs": [], "source": [ + "desc_mean = dataset_train.desc_vals.mean(dim=0)\n", + "desc_std = dataset_train.desc_vals.std(dim=0).clamp_min(1e-6)\n", + "dataset_train.desc_vals = (dataset_train.desc_vals - desc_mean) / desc_std\n", + "dataset_test.desc_vals = (dataset_test.desc_vals - desc_mean) / desc_std\n", + "print(\n", + " \"Train descriptor mean/std (post-scale):\",\n", + " float(dataset_train.desc_vals.mean()),\n", + " float(dataset_train.desc_vals.std()),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "a99b3306", + "metadata": {}, + "source": [ + "## Train a small network\n", + "\n", + "Architecture is modest (`hidden_dim=128`, two hidden layers) with 40 epochs and early stopping. Re-seed immediately before constructing `ECNet` so initialization matches across Restart & Run All on the same descriptor matrix." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fccbe781", + "metadata": {}, + "outputs": [], + "source": [ + "torch.manual_seed(SEED)\n", "model = ECNet(dataset_train.desc_vals.shape[1], 1, 128, 2)\n", "train_loss, valid_loss = model.fit(\n", - " dataset=dataset_train, valid_size=0.2,verbose=5,\n", - " patience=50, epochs=300, random_state=24\n", + " dataset=dataset_train,\n", + " valid_size=0.2,\n", + " verbose=10,\n", + " patience=16,\n", + " epochs=40,\n", + " random_state=SEED,\n", ")" ] }, + { + "cell_type": "markdown", + "id": "fb569f39", + "metadata": {}, + "source": [ + "### Learning curves\n", + "\n", + "Plot $\\sqrt{\\mathrm{MSE}}$ so the scale is closer to CN units. Both curves should drop sharply in the first epochs after scaling; validation will still wiggle because the validation split is a fraction of the training molecules." + ] + }, { "cell_type": "code", - "execution_count": 6, - "id": "bound-essex", + "execution_count": null, + "id": "021e0bdf", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ - "train_loss = [sqrt(l) for l in train_loss][5:]\n", - "valid_loss = [sqrt(l) for l in valid_loss][5:]\n", - "epoch = [i for i in range(len(train_loss))]\n", + "train_curve = [sqrt(float(loss)) for loss in train_loss]\n", + "valid_curve = [sqrt(float(loss)) for loss in valid_loss]\n", + "epochs = list(range(len(train_curve)))\n", + "\n", "plt.clf()\n", - "plt.xlabel('Epochs')\n", - "plt.ylabel('Sqrt(Loss)')\n", - "plt.plot(epoch, train_loss, color='blue', label='Training Loss')\n", - "plt.plot(epoch, valid_loss, color='red', label='Validation Loss')\n", - "plt.legend(loc='upper right')\n", + "plt.xlabel(\"Epochs\")\n", + "plt.ylabel(\"Sqrt(Loss)\")\n", + "plt.plot(epochs, train_curve, color=\"blue\", label=\"Training\")\n", + "plt.plot(epochs, valid_curve, color=\"red\", label=\"Validation\")\n", + "plt.legend(loc=\"upper right\")\n", "plt.show()" ] }, { - "cell_type": "code", - "execution_count": 7, - "id": "electronic-traffic", + "cell_type": "markdown", + "id": "5548e5a3", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Training median absolute error: 3.772597312927246\n", - "Training r-squared coefficient: 0.8910070089699159\n", - "Testing median absolute error: 5.318617820739746\n", - "Testing r-squared coefficient: 0.779898764385632\n" - ] - } - ], "source": [ - "y_hat_train = model(dataset_train.desc_vals).detach().numpy()\n", - "y_train = dataset_train.target_vals\n", - "train_mae = median_absolute_error(y_hat_train, y_train)\n", - "train_r2 = r2_score(y_hat_train, y_train)\n", - "y_hat_test = model(dataset_test.desc_vals).detach().numpy()\n", - "y_test = dataset_test.target_vals\n", - "test_mae = median_absolute_error(y_hat_test, y_test)\n", - "test_r2 = r2_score(y_hat_test, y_test)\n", - "print('Training median absolute error: {}'.format(train_mae))\n", - "print('Training r-squared coefficient: {}'.format(train_r2))\n", - "print('Testing median absolute error: {}'.format(test_mae))\n", - "print('Testing r-squared coefficient: {}'.format(test_r2))" + "## Results\n", + "\n", + "Report median absolute error and $R^2$ with sklearn’s `(y_true, y_pred)` order. With scaling and short training you should see a clear trend on the parity plot and test error in the same ballpark as train error — not a publication-grade CN model.\n", + "\n", + "The dashed line is $y = x$. Points above it are over-predictions; points below are under-predictions." ] }, { "cell_type": "code", - "execution_count": 8, - "id": "loose-coral", + "execution_count": null, + "id": "85a22b93", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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mtxNdp599ln+5E/+JHIbQ0OODWHjRmeFsHRpefV1lWJ8fJzOzk6MMXzyk5/k8MMP58orryzrPeO+e0VRqoOIrDXG+Pq/N/VKARwBMDICk5POZyMIBIBbb72Vnp4ejjrqKLZv384ll1xS7S4pilIHNLWhuZG58sory74yUBSl8Wj6lYKiKIqyHxUKiqIoShYVCoqiKEoWFQqKoihKFhUKJaaY1NngJLnzZkFdvnw53/nOdwJqROenP/0pxx9/PMcddxzz58/nW9/6Vqy+KIrS+Kj3UYkJS50dxv33309nZ2d2z4RLL720JP3au3cvAwMDPPbYY8ydO5fXX389G/EctS+KojQ+ulKoQO7stWvX8va3v50FCxbwnve8JxsdfNNNNzF//nyOPfZYLrjgAkZGRli+fDlf//rX6enpYc2aNVx//fV89atfBeCMM87g6quv5sQTT+Stb30ra9asAZzNcj7ykY8wf/58zjvvPE466SRyg/p27NjBvn37SCaTgJMz6YgjjgBg69atfOhDH+KEE07ghBNO4KGHHvLti6IopaUSqfvj0twrhcyGp5lUqZkNT6FkUWzGGC6//HJ+9KMfcdBBB3HXXXexZMkSbr/9dr70pS+xYcMGpk+fziuvvMIBBxzApZdeOmV1sXr16int7du3j8cee4xVq1bxhS98gfvuu49bbrmFAw88kPXr1/P000/T09OT14/Zs2fzwQ9+MJvb6JxzzqGvr4+WlhYWL17MlVdeyemnn87GjRt5z3vew7PPPpvXF0VRSkcFhp+CaG6hUIHc2a+//jpPP/00Z511FgATExMccsghANkcQueeey7nnntupPbOP/98ABYsWJBV/zz44IMsXrwYgKOPPppjjz3Wt+5tt93GU089xX333cdXv/pV7r33Xu644w7uu+8+1q9fn73u1VdfDd2WU1GU4qhk6v44NLdQqEDubGMMRx11FI888kjeuX/913/lgQce4Cc/+QlLly7NbosZRCZVdiFpsgGOOeYYjjnmGC666CLmzZvHHXfcweTkJL/85S+ZMWNG7PYURSmMSqbuj0Nz2xQqkDt7+vTpbN26NSsU9u7dyzPPPMPk5CSbNm3iHe94B1/+8pfZvn074+PjzJo1ix07dsS6x2mnncb3v/99ANavX+8rXMbHx7n//vuz35944glSqRQA7373u7n55punnAMK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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ + "y_hat_train = model(dataset_train.desc_vals).detach().numpy()\n", + "y_train = dataset_train.target_vals.detach().numpy()\n", + "y_hat_test = model(dataset_test.desc_vals).detach().numpy()\n", + "y_test = dataset_test.target_vals.detach().numpy()\n", + "\n", + "train_mae = median_absolute_error(y_train, y_hat_train)\n", + "train_r2 = r2_score(y_train, y_hat_train)\n", + "test_mae = median_absolute_error(y_test, y_hat_test)\n", + "test_r2 = r2_score(y_test, y_hat_test)\n", + "print(f\"Training median absolute error: {train_mae}\")\n", + "print(f\"Training r-squared coefficient: {train_r2}\")\n", + "print(f\"Testing median absolute error: {test_mae}\")\n", + "print(f\"Testing r-squared coefficient: {test_r2}\")\n", + "\n", + "lo = float(min(y_train.min(), y_hat_train.min(), y_test.min(), y_hat_test.min()))\n", + "hi = float(max(y_train.max(), y_hat_train.max(), y_test.max(), y_hat_test.max()))\n", + "\n", "plt.clf()\n", - "plt.xlabel('Experimental CN Value')\n", - "plt.ylabel('Predicted CN Value')\n", - "plt.scatter(y_train, y_hat_train, color='blue', label='Training Set')\n", - "plt.scatter(y_test, y_hat_test, color='red', label='Testing Set')\n", - "plt.legend(loc='upper left')\n", + "plt.xlabel(\"Experimental CN\")\n", + "plt.ylabel(\"Predicted CN\")\n", + "plt.plot([lo, hi], [lo, hi], \"k--\", linewidth=1, label=\"Ideal\")\n", + "plt.scatter(y_train, y_hat_train, color=\"blue\", label=\"Training\")\n", + "plt.scatter(y_test, y_hat_test, color=\"red\", label=\"Testing\")\n", + "plt.gca().set_aspect(\"equal\", adjustable=\"box\")\n", + "plt.legend(loc=\"upper left\")\n", "plt.show()" ] }, { - "cell_type": "code", - "execution_count": 9, - "id": "hispanic-daisy", + "cell_type": "markdown", + "id": "8b51582b", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Median median absolute error: 5.252781867980957\n", - "Median r-squared score: 0.8107531026041015\n" - ] - } - ], "source": [ - "test_maes = []\n", - "test_r2s = []\n", - "for _ in range(25):\n", - " model = ECNet(dataset_train.desc_vals.shape[1], 1, 128, 2)\n", - " model.fit(dataset=dataset_train, valid_size=0.2, patience=50, epochs=300, random_state=24)\n", - " y_hat_test = model(dataset_test.desc_vals).detach().numpy()\n", - " y_test = dataset_test.target_vals\n", - " test_maes.append(median_absolute_error(y_hat_test, y_test))\n", - " test_r2s.append(r2_score(y_hat_test, y_test))\n", - "print('Median median absolute error: {}'.format(np.median(test_maes)))\n", - "print('Median r-squared score: {}'.format(np.median(test_r2s)))" + "## Multi-seed smoke\n", + "\n", + "Refit five times with distinct seeds (weight init + validation split) on the **same** scaled feature matrix. Medians summarize sensitivity to initialization under short training — this is a smoke check, not a full uncertainty study." ] }, { "cell_type": "code", "execution_count": null, - "id": "efficient-rocket", + "id": "a1fd4f55", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "test_maes = []\n", + "test_r2s = []\n", + "for trial_seed in range(SEED, SEED + 5):\n", + " torch.manual_seed(trial_seed)\n", + " trial = ECNet(dataset_train.desc_vals.shape[1], 1, 128, 2)\n", + " trial.fit(\n", + " dataset=dataset_train,\n", + " valid_size=0.2,\n", + " patience=16,\n", + " epochs=40,\n", + " random_state=trial_seed,\n", + " )\n", + " y_hat = trial(dataset_test.desc_vals).detach().numpy()\n", + " test_maes.append(median_absolute_error(y_test, y_hat))\n", + " test_r2s.append(r2_score(y_test, y_hat))\n", + "\n", + "print(f\"Median median absolute error: {np.median(test_maes)}\")\n", + "print(f\"Median r-squared coefficient: {np.median(test_r2s)}\")\n", + "print(f\"Per-seed test MAE: {np.round(test_maes, 3).tolist()}\")\n", + "print(f\"Per-seed test R^2: {np.round(test_r2s, 3).tolist()}\")" + ] + }, + { + "cell_type": "markdown", + "id": "b0c774f5", + "metadata": {}, + "source": [ + "## Interpretation\n", + "\n", + "With train-only feature selection and scaling, even a short fit should recover a usable CN trend. If you skip scaling, epoch-0 losses stay enormous and $R^2$ often collapses near zero — that is a descriptor-scale issue, not evidence that CN is unpredictable. For stronger models, raise `epochs`, tune architecture, and expand data beyond this bundled set." + ] + }, + { + "cell_type": "markdown", + "id": "48d7dc26", + "metadata": {}, + "source": [ + "## Takeaways\n", + "\n", + "- Use `backend=\"padel\"` unless you have alvaDesc; Java is required for PaDEL.\n", + "- Run `select_rfr` and compute scaling statistics on the **training** split only.\n", + "- Standardize wide-ranging PaDEL columns before `ECNet.fit` for short demos.\n", + "- Pin `SEED`, single-thread BLAS/`n_jobs=1`, and `torch.manual_seed`; treat one Restart & Run All (one `load_cn`) as the reproducible unit.\n", + "- sklearn metrics take `(y_true, y_pred)`; parity plots need an identity line and equal axes.\n", + "- Multi-seed loops must change the seed each trial.\n", + "- Strip cell outputs before committing.\n" + ] + }, + { + "cell_type": "markdown", + "id": "9da506aa", + "metadata": {}, + "source": [ + "## Further reading\n", + "\n", + "- Kessler, T., & Mack, J. H. (2017). ECNet: Large scale machine learning projects for fuel property prediction. *Journal of Open Source Software*, 2(17), 401. https://doi.org/10.21105/joss.00401\n", + "- Kessler, T., Sacia, E. R., Bell, A. T., & Mack, J. H. (2017). Artificial neural network based predictions of cetane number for furanic biofuel additives. *Fuel*, 206, 171–179. https://doi.org/10.1016/j.fuel.2017.06.015\n", + "- Yap, C. W. (2011). PaDEL-Descriptor: An open source software to calculate molecular descriptors and fingerprints. *Journal of Computational Chemistry*, 32(7), 1466–1474. https://doi.org/10.1002/jcc.21707\n", + "- Breiman, L. (2001). Random forests. *Machine Learning*, 45(1), 5–32. https://doi.org/10.1023/A:1010933404324\n", + "- [Quickstart](https://ecnet.readthedocs.io/en/latest/quickstart.html) · [API reference](https://ecnet.readthedocs.io/en/latest/api.html)\n", + "- Related examples: `getting_started.ipynb`, `example_multiprop.ipynb`\n" + ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "ecnet", "language": "python", "name": "python3" }, @@ -304,7 +377,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.8" + "version": "3.12.13" } }, "nbformat": 4, diff --git a/examples/example_multiprop.ipynb b/examples/example_multiprop.ipynb index 24e2601..effd68e 100644 --- a/examples/example_multiprop.ipynb +++ b/examples/example_multiprop.ipynb @@ -1,76 +1,136 @@ { "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Multi-property CN + YSI example (v4)\n", + "\n", + "Build a joint cetane-number (CN) / yield sooting index (YSI) dataset from compounds shared by `load_cn` and `load_ysi`, then train a two-output `ECNet` with the current public API:\n", + "intersection → PaDEL → split → multi-target `select_rfr` → descriptor scaling → `ECNet.fit` → per-property metrics and parity plots.\n", + "\n", + "YSI targets are divided by 10 for training (both heads on a closer scale) and multiplied back by 10 for reported metrics and plots. PaDEL columns are standardized with **training-set** statistics — without that step, short multiprop fits often produce huge losses and nonsensical parity outliers.\n", + "\n", + "**Requirements:** Python 3.11+, ECNet, Java for PaDEL. Commit notebooks **without** stored outputs." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Pin BLAS/OpenMP to one thread before NumPy/scikit-learn import, seed RNGs, and suppress library warnings. `SEED` stabilizes splits, RF selection, weight init, and training for **this session’s** descriptor matrix (one PaDEL build per Restart & Run All)." + ] + }, { "cell_type": "code", - "execution_count": 1, - "id": "monetary-setting", + "execution_count": null, + "id": "6dcd5fd2", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "132 132 2\n" - ] - } - ], + "outputs": [], "source": [ + "import os\n", + "\n", + "os.environ[\"OMP_NUM_THREADS\"] = \"1\"\n", + "os.environ[\"MKL_NUM_THREADS\"] = \"1\"\n", + "os.environ[\"OPENBLAS_NUM_THREADS\"] = \"1\"\n", + "\n", + "import random\n", + "import warnings\n", + "from copy import deepcopy\n", + "from math import sqrt\n", + "\n", + "import numpy as np\n", + "import torch\n", + "from matplotlib import pyplot as plt\n", + "from sklearn.metrics import median_absolute_error, r2_score\n", + "from sklearn.model_selection import train_test_split\n", + "\n", + "from ecnet import ECNet\n", "from ecnet.datasets import load_cn, load_ysi\n", + "from ecnet.datasets.structs import QSPRDataset\n", + "from ecnet.tasks.feature_selection import select_rfr\n", + "\n", + "warnings.filterwarnings(\"ignore\")\n", + "torch.set_num_threads(1)\n", + "\n", + "SEED = 42\n", + "random.seed(SEED)\n", + "np.random.seed(SEED)\n", + "torch.manual_seed(SEED)" + ] + }, + { + "cell_type": "markdown", + "id": "bef8f9b4", + "metadata": {}, + "source": [ + "## Motivation\n", + "\n", + "Fuel screening often needs several endpoints at once. A multi-output network shares descriptors across CN and YSI so related structure–property signals can transfer. ECNet’s multiprop path and CN/YSI context are described in [Kessler & Mack, JOSS (2017)](https://doi.org/10.21105/joss.00401) and related CN work in [Kessler et al., *Fuel* (2017)](https://doi.org/10.1016/j.fuel.2017.06.015); descriptors come from [PaDEL](https://doi.org/10.1002/jcc.21707)." + ] + }, + { + "cell_type": "markdown", + "id": "5bff322a", + "metadata": {}, + "source": [ + "## Joint dataset\n", + "\n", + "Keep molecules that appear in both bundled loaders. Scale YSI by $1/10$ in the target matrix used for training." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "826f90c3", + "metadata": {}, + "outputs": [], + "source": [ "smiles_cn, cn = load_cn()\n", "smiles_ysi, ysi = load_ysi()\n", "ysi = [[y[0] / 10.0] for y in ysi]\n", + "\n", + "ysi_by_smi = {s: y for s, y in zip(smiles_ysi, ysi)}\n", "smiles_common = []\n", "vals_common = []\n", - "for idx, csmi in enumerate(smiles_cn):\n", - " for ysmi in smiles_ysi:\n", - " if csmi == ysmi:\n", - " smiles_common.append(csmi)\n", - " vals_common.append([cn[idx][0], ysi[idx][0]])\n", - "print(len(smiles_common), len(vals_common), len(vals_common[0]))" + "for smi, cval in zip(smiles_cn, cn):\n", + " if smi in ysi_by_smi:\n", + " smiles_common.append(smi)\n", + " vals_common.append([cval[0], ysi_by_smi[smi][0]])\n", + "\n", + "print(len(smiles_common), \"molecules,\", len(vals_common[0]), \"targets (CN, YSI/10)\")\n", + "\n", + "dataset = QSPRDataset(\n", + " smiles=smiles_common, target_vals=vals_common, backend=\"padel\"\n", + ")\n", + "print(dataset.desc_vals.shape, dataset.target_vals.shape)" ] }, { - "cell_type": "code", - "execution_count": 2, - "id": "mighty-parking", + "cell_type": "markdown", + "id": "d96f35b0", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "torch.Size([132, 5305]) torch.Size([132, 2])\n" - ] - } - ], "source": [ - "from ecnet.datasets.structs import QSPRDataset\n", - "dataset = QSPRDataset(smiles=smiles_common, target_vals=vals_common, backend='alvadesc')\n", - "print(dataset.desc_vals.shape, dataset.target_vals.shape)" + "## Train / test split\n", + "\n", + "Hold out 20% for testing. Feature selection and descriptor scaling use the training subset only." ] }, { "cell_type": "code", - "execution_count": 3, - "id": "spectacular-heating", - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "torch.Size([105, 5305]) torch.Size([27, 5305])\n" - ] - } - ], + "execution_count": null, + "id": "d2561db3", + "metadata": {}, + "outputs": [], "source": [ - "from sklearn.model_selection import train_test_split\n", - "from copy import deepcopy\n", + "index_train, index_test = train_test_split(\n", + " [i for i in range(len(dataset))],\n", + " test_size=0.2,\n", + " random_state=SEED,\n", + ")\n", "\n", - "index_train, index_test = train_test_split([i for i in range(len(dataset))],\n", - " test_size=0.2, random_state=42)\n", "dataset_train = deepcopy(dataset)\n", "dataset_train.set_index(index_train)\n", "dataset_test = deepcopy(dataset)\n", @@ -78,262 +138,287 @@ "print(dataset_train.desc_vals.shape, dataset_test.desc_vals.shape)" ] }, + { + "cell_type": "markdown", + "id": "a79bc9a0", + "metadata": {}, + "source": [ + "## Feature selection (multi-target)\n", + "\n", + "`select_rfr` fits a random forest on **column 0** of `target_vals` ([Breiman, 2001](https://doi.org/10.1023/A:1010933404324)). For a joint CN/YSI rank, temporarily put a unitless sum of z-scored CN and YSI/10 in that column, run selection, then restore the real two-column targets." + ] + }, { "cell_type": "code", - "execution_count": 4, - "id": "supposed-confirmation", + "execution_count": null, + "id": "da9432c9", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "torch.Size([105, 219]) torch.Size([27, 219])\n", - "['PHI', 'S2K', 'RBF', 'ChiA_B(s)', 'Chi_Dz(v)'] 219\n", - "[0.24915499070661248, 0.09787116659947559, 0.0480861330204689, 0.040167452915412266, 0.03690466807390161]\n" - ] - } - ], + "outputs": [], "source": [ - "from ecnet.tasks.feature_selection import select_rfr\n", - "desc_idx, desc_imp = select_rfr(dataset_train, total_importance=0.95,\n", - " n_estimators=100, n_jobs=4)\n", + "cn_train = dataset_train.target_vals[:, 0]\n", + "ysi_train = dataset_train.target_vals[:, 1]\n", + "cn_z = (cn_train - cn_train.mean()) / cn_train.std().clamp_min(1e-6)\n", + "ysi_z = (ysi_train - ysi_train.mean()) / ysi_train.std().clamp_min(1e-6)\n", + "\n", + "targets_saved = dataset_train.target_vals.clone()\n", + "dataset_train.target_vals = torch.stack([cn_z + ysi_z, ysi_train], dim=1)\n", + "\n", + "desc_idx, desc_imp = select_rfr(\n", + " dataset_train,\n", + " total_importance=0.95,\n", + " n_estimators=100,\n", + " n_jobs=1,\n", + " random_state=SEED,\n", + ")\n", + "dataset_train.target_vals = targets_saved\n", + "\n", "dataset_train.set_desc_index(desc_idx)\n", "dataset_test.set_desc_index(desc_idx)\n", "desc_names = [dataset.desc_names[i] for i in desc_idx]\n", "print(dataset_train.desc_vals.shape, dataset_test.desc_vals.shape)\n", "print(desc_names[:5], len(desc_names))\n", - "print(desc_imp[:5])" + "\n", + "plt.clf()\n", + "plt.xlabel(\"N top descriptors\")\n", + "plt.ylabel(\"Cumulative importance\")\n", + "cum = [sum(desc_imp[: i + 1]) for i in range(len(desc_imp))]\n", + "plt.plot(range(1, len(cum) + 1), cum, color=\"blue\")\n", + "plt.axhline(0.95, color=\"gray\", linestyle=\"--\", linewidth=1)\n", + "plt.show()" ] }, { - "cell_type": "code", - "execution_count": 5, - "id": "found-bachelor", + "cell_type": "markdown", + "id": "1b9c2636", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], "source": [ - "from matplotlib import pyplot as plt\n", - "plt.clf()\n", - "x_vals = [i for i in range(len(desc_imp))]\n", - "y_vals = [0.0]\n", - "for idx in range(len(desc_imp) - 1):\n", - " y_vals.append(sum(desc_imp[:idx + 1]))\n", - "x_vals = x_vals[:250]\n", - "y_vals = y_vals[:250]\n", - "plt.xlabel('N Top Descriptors')\n", - "plt.ylabel('Total Importance')\n", - "plt.plot(x_vals, y_vals, color='blue')\n", - "plt.show()" + "## Descriptor scaling\n", + "\n", + "Standardize descriptors with the training mean and standard deviation, then apply the same transform to the test set." ] }, { "cell_type": "code", - "execution_count": 91, - "id": "biological-blend", + "execution_count": null, + "id": "35d52dac", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Epoch: 0 | Train loss: 937.6482631138393 | Valid loss: 9223372036854775807\n", - "Epoch: 10 | Train loss: 515.2571847098214 | Valid loss: 381.62640380859375\n", - "Epoch: 20 | Train loss: 277.794430687314 | Valid loss: 168.47216796875\n", - "Epoch: 30 | Train loss: 199.46808079310827 | Valid loss: 175.9431915283203\n", - "Epoch: 40 | Train loss: 129.08699180966332 | Valid loss: 97.55359649658203\n", - "Epoch: 50 | Train loss: 87.86568505423409 | Valid loss: 79.12625122070312\n", - "Epoch: 60 | Train loss: 63.46900503976004 | Valid loss: 83.04481506347656\n", - "Epoch: 70 | Train loss: 50.02490543183826 | Valid loss: 95.28618621826172\n", - "Epoch: 80 | Train loss: 58.62040819440569 | Valid loss: 88.38362884521484\n", - "Epoch: 90 | Train loss: 58.35040928068615 | Valid loss: 139.80828857421875\n", - "Epoch: 100 | Train loss: 32.42879213605608 | Valid loss: 102.90657806396484\n", - "Epoch: 110 | Train loss: 37.625792003813245 | Valid loss: 111.71890258789062\n", - "Epoch: 120 | Train loss: 29.117579868861608 | Valid loss: 104.37113952636719\n", - "Epoch: 130 | Train loss: 22.077698298863 | Valid loss: 118.02700805664062\n", - "Epoch: 140 | Train loss: 21.84637941632952 | Valid loss: 114.7500228881836\n", - "Epoch: 150 | Train loss: 23.50521587190174 | Valid loss: 113.68724060058594\n" - ] - } - ], + "outputs": [], "source": [ - "from ecnet import ECNet\n", - "\n", - "model = ECNet(dataset_train.desc_vals.shape[1], dataset_train.target_vals.shape[1],\n", - " 256, 2)\n", - "train_loss, valid_loss = model.fit(\n", - " dataset=dataset_train, valid_size=0.2, verbose=10,\n", - " patience=100, epochs=500, random_state=12, lr=0.002, lr_decay=0.000005\n", + "desc_mean = dataset_train.desc_vals.mean(dim=0)\n", + "desc_std = dataset_train.desc_vals.std(dim=0).clamp_min(1e-6)\n", + "dataset_train.desc_vals = (dataset_train.desc_vals - desc_mean) / desc_std\n", + "dataset_test.desc_vals = (dataset_test.desc_vals - desc_mean) / desc_std\n", + "print(\n", + " \"Train descriptor mean/std (post-scale):\",\n", + " float(dataset_train.desc_vals.mean()),\n", + " float(dataset_train.desc_vals.std()),\n", ")" ] }, { - "cell_type": "code", - "execution_count": 92, - "id": "serious-bangkok", + "cell_type": "markdown", + "id": "88cdf5a7", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], "source": [ - "from math import sqrt\n", + "## Train a two-output network\n", "\n", - "train_loss = [sqrt(l) for l in train_loss][5:]\n", - "valid_loss = [sqrt(l) for l in valid_loss][5:]\n", - "epoch = [i for i in range(5, len(train_loss) + 5)]\n", - "plt.clf()\n", - "plt.xlabel('Epochs')\n", - "plt.ylabel('Sqrt(Loss)')\n", - "plt.plot(epoch, train_loss, color='blue', label='Training Loss')\n", - "plt.plot(epoch, valid_loss, color='red', label='Validation Loss')\n", - "plt.legend(loc='upper right')\n", - "plt.show()" + "`output_dim=2` for (CN, YSI/10). Short training (40 epochs) with a modest MLP; re-seed before construction so initialization is stable within the session." ] }, { "cell_type": "code", - "execution_count": 93, - "id": "progressive-disposal", + "execution_count": null, + "id": "4389e390", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "CN Train: 2.1248035430908203 | 0.9484218050927915\n", - "CN Test: 4.027780532836914 | 0.04385905048079386\n", - "YSI Train: 17.089157104492188 | 0.8958528563863021\n", - "YSI Test: 5.721216201782227 | 0.09395164818501989\n" - ] - } - ], + "outputs": [], "source": [ - "from sklearn.metrics import median_absolute_error, r2_score\n", + "torch.manual_seed(SEED)\n", + "model = ECNet(\n", + " dataset_train.desc_vals.shape[1],\n", + " dataset_train.target_vals.shape[1],\n", + " 128,\n", + " 2,\n", + ")\n", + "train_loss, valid_loss = model.fit(\n", + " dataset=dataset_train,\n", + " valid_size=0.2,\n", + " verbose=10,\n", + " patience=16,\n", + " epochs=40,\n", + " random_state=SEED,\n", + " lr=0.002,\n", + " lr_decay=0.000005,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "0e2ac1fd", + "metadata": {}, + "source": [ + "### Learning curves\n", "\n", - "y_hat_train = model(dataset_train.desc_vals).detach().numpy()\n", - "y_train = dataset_train.target_vals.numpy()\n", - "y_hat_test = model(dataset_test.desc_vals).detach().numpy()\n", - "y_test = dataset_test.target_vals.numpy()\n", - "\n", - "y_hat_train_cn = [y[0] for y in y_hat_train]\n", - "y_hat_train_ysi = [y[1] * 10 for y in y_hat_train]\n", - "y_train_cn = [y[0] for y in y_train]\n", - "y_train_ysi = [y[1] * 10 for y in y_train]\n", - "\n", - "y_hat_test_cn = [y[0] for y in y_hat_test]\n", - "y_hat_test_ysi = [y[1] for y in y_hat_test]\n", - "y_test_cn = [y[0] for y in y_test]\n", - "y_test_ysi = [y[1] for y in y_test]\n", - "\n", - "mae_cn_train = median_absolute_error(y_hat_train_cn, y_train_cn)\n", - "mae_cn_test = median_absolute_error(y_hat_test_cn, y_test_cn)\n", - "mae_ysi_train = median_absolute_error(y_hat_train_ysi, y_train_ysi)\n", - "mae_ysi_test = median_absolute_error(y_hat_test_ysi, y_test_ysi)\n", - "r2_cn_train = r2_score(y_hat_train_cn, y_train_cn)\n", - "r2_cn_test = r2_score(y_hat_test_cn, y_test_cn)\n", - "r2_ysi_train = r2_score(y_hat_train_ysi, y_train_ysi)\n", - "r2_ysi_test = r2_score(y_hat_test_ysi, y_test_ysi)\n", - "\n", - "print(f'CN Train: {mae_cn_train} | {r2_cn_train}')\n", - "print(f'CN Test: {mae_cn_test} | {r2_cn_test}')\n", - "print(f'YSI Train: {mae_ysi_train} | {r2_ysi_train}')\n", - "print(f'YSI Test: {mae_ysi_test} | {r2_ysi_test}')" + "Plot $\\sqrt{\\mathrm{MSE}}$ over **all** epochs. After descriptor scaling, epoch 0 should already be on a human-scale axis (hundreds, not millions)." ] }, { "cell_type": "code", - "execution_count": 94, - "id": "recent-biodiversity", + "execution_count": null, + "id": "b2e817f7", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ + "train_curve = [sqrt(float(loss)) for loss in train_loss]\n", + "valid_curve = [sqrt(float(loss)) for loss in valid_loss]\n", + "epochs = list(range(len(train_curve)))\n", + "\n", "plt.clf()\n", - "plt.xlabel('Experimental CN')\n", - "plt.ylabel('Predicted CN')\n", - "plt.scatter(y_train_cn, y_hat_train_cn, color='blue', label='Training Set')\n", - "plt.scatter(y_test_cn, y_hat_test_cn, color='red', label='Testing Set')\n", - "plt.legend(loc='upper left')\n", + "plt.xlabel(\"Epochs\")\n", + "plt.ylabel(\"Sqrt(Loss)\")\n", + "plt.plot(epochs, train_curve, color=\"blue\", label=\"Training\")\n", + "plt.plot(epochs, valid_curve, color=\"red\", label=\"Validation\")\n", + "plt.legend(loc=\"upper right\")\n", "plt.show()" ] }, + { + "cell_type": "markdown", + "id": "52b18476", + "metadata": {}, + "source": [ + "## Results\n", + "\n", + "Report median absolute error and $R^2$ per property with sklearn’s `(y_true, y_pred)` order. YSI metrics/plots use the original scale ($\\times 10$). Dashed lines are $y = x$." + ] + }, { "cell_type": "code", - "execution_count": 95, - "id": "third-knowing", + "execution_count": null, + "id": "dd9e4088", "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ - "plt.clf()\n", - "plt.xlabel('Experimental YSI')\n", - "plt.ylabel('Predicted YSI')\n", - "plt.scatter(y_train_ysi, y_hat_train_ysi, color='blue', label='Training Set')\n", - "plt.scatter(y_test_ysi, y_hat_test_ysi, color='red', label='Testing Set')\n", - "plt.legend(loc='upper left')\n", - "plt.show()" + "y_hat_train = model(dataset_train.desc_vals).detach().numpy()\n", + "y_train = dataset_train.target_vals.detach().numpy()\n", + "y_hat_test = model(dataset_test.desc_vals).detach().numpy()\n", + "y_test = dataset_test.target_vals.detach().numpy()\n", + "\n", + "y_hat_train_cn = y_hat_train[:, 0]\n", + "y_train_cn = y_train[:, 0]\n", + "y_hat_test_cn = y_hat_test[:, 0]\n", + "y_test_cn = y_test[:, 0]\n", + "\n", + "y_hat_train_ysi = y_hat_train[:, 1] * 10.0\n", + "y_train_ysi = y_train[:, 1] * 10.0\n", + "y_hat_test_ysi = y_hat_test[:, 1] * 10.0\n", + "y_test_ysi = y_test[:, 1] * 10.0\n", + "\n", + "print(\"CN train MAE:\", median_absolute_error(y_train_cn, y_hat_train_cn))\n", + "print(\"CN train R^2:\", r2_score(y_train_cn, y_hat_train_cn))\n", + "print(\"CN test MAE:\", median_absolute_error(y_test_cn, y_hat_test_cn))\n", + "print(\"CN test R^2:\", r2_score(y_test_cn, y_hat_test_cn))\n", + "print(\"YSI train MAE:\", median_absolute_error(y_train_ysi, y_hat_train_ysi))\n", + "print(\"YSI train R^2:\", r2_score(y_train_ysi, y_hat_train_ysi))\n", + "print(\"YSI test MAE:\", median_absolute_error(y_test_ysi, y_hat_test_ysi))\n", + "print(\"YSI test R^2:\", r2_score(y_test_ysi, y_hat_test_ysi))" ] }, { "cell_type": "code", "execution_count": null, - "id": "pacific-spectacular", + "id": "f98b3861", "metadata": {}, "outputs": [], - "source": [] + "source": [ + "def parity_plot(y_true_train, y_pred_train, y_true_test, y_pred_test, xlabel, ylabel):\n", + " lo = float(\n", + " min(\n", + " np.min(y_true_train),\n", + " np.min(y_pred_train),\n", + " np.min(y_true_test),\n", + " np.min(y_pred_test),\n", + " )\n", + " )\n", + " hi = float(\n", + " max(\n", + " np.max(y_true_train),\n", + " np.max(y_pred_train),\n", + " np.max(y_true_test),\n", + " np.max(y_pred_test),\n", + " )\n", + " )\n", + " plt.clf()\n", + " plt.xlabel(xlabel)\n", + " plt.ylabel(ylabel)\n", + " plt.plot([lo, hi], [lo, hi], \"k--\", linewidth=1, label=\"Ideal\")\n", + " plt.scatter(y_true_train, y_pred_train, color=\"blue\", label=\"Training\")\n", + " plt.scatter(y_true_test, y_pred_test, color=\"red\", label=\"Testing\")\n", + " plt.gca().set_aspect(\"equal\", adjustable=\"box\")\n", + " plt.legend(loc=\"upper left\")\n", + " plt.show()\n", + "\n", + "\n", + "parity_plot(\n", + " y_train_cn,\n", + " y_hat_train_cn,\n", + " y_test_cn,\n", + " y_hat_test_cn,\n", + " \"Experimental CN\",\n", + " \"Predicted CN\",\n", + ")\n", + "parity_plot(\n", + " y_train_ysi,\n", + " y_hat_train_ysi,\n", + " y_test_ysi,\n", + " y_hat_test_ysi,\n", + " \"Experimental YSI\",\n", + " \"Predicted YSI\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "89848d9f", + "metadata": {}, + "source": [ + "## Interpretation\n", + "\n", + "With combined-target feature selection and train-only descriptor scaling, both heads should track the ideal line on a demo-scale fit. CN test $R^2$ is often lower than YSI here because the shared intersection set is small ($N=132$) and the joint MSE loss weights both heads together. Skipping descriptor scaling recreates enormous epoch-0 losses and wild negative predictions — a scale issue, not proof that multiprop CN/YSI is infeasible." + ] + }, + { + "cell_type": "markdown", + "id": "782b5c6d", + "metadata": {}, + "source": [ + "## Takeaways\n", + "\n", + "- Intersect SMILES across loaders before building a multi-output `QSPRDataset`.\n", + "- `select_rfr` reads target column 0 only — use a temporary combined score for joint ranking, then restore real targets.\n", + "- Scale YSI for training if magnitudes differ sharply from CN; always standardize wide PaDEL columns with train-only stats.\n", + "- Pin `SEED`, single-thread BLAS/`n_jobs=1`, and `torch.manual_seed`; treat one Restart & Run All (one PaDEL build) as the reproducible unit — descriptor counts can drift across fresh kernel restarts.\n", + "- Report per-property MAE/$R^2$ with `(y_true, y_pred)`; parity plots need $y=x$ and equal axes.\n", + "- Strip cell outputs before committing.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Further reading\n", + "\n", + "- Kessler, T., & Mack, J. H. (2017). ECNet: Large scale machine learning projects for fuel property prediction. *Journal of Open Source Software*, 2(17), 401. https://doi.org/10.21105/joss.00401\n", + "- Kessler, T., Sacia, E. R., Bell, A. T., & Mack, J. H. (2017). Artificial neural network based predictions of cetane number for furanic biofuel additives. *Fuel*, 206, 171–179. https://doi.org/10.1016/j.fuel.2017.06.015\n", + "- Yap, C. W. (2011). PaDEL-Descriptor: An open source software to calculate molecular descriptors and fingerprints. *Journal of Computational Chemistry*, 32(7), 1466–1474. https://doi.org/10.1002/jcc.21707\n", + "- Breiman, L. (2001). Random forests. *Machine Learning*, 45(1), 5–32. https://doi.org/10.1023/A:1010933404324\n", + "- [Quickstart](https://ecnet.readthedocs.io/en/latest/quickstart.html) · [API reference](https://ecnet.readthedocs.io/en/latest/api.html)\n", + "- Related examples: `getting_started.ipynb`, `example.ipynb`" + ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "ecnet", "language": "python", "name": "python3" }, @@ -347,7 +432,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.8" + "version": "3.12.13" } }, "nbformat": 4, diff --git a/examples/getting_started.ipynb b/examples/getting_started.ipynb index 866fb07..cd7b0bb 100644 --- a/examples/getting_started.ipynb +++ b/examples/getting_started.ipynb @@ -1,372 +1,305 @@ { - "metadata": { - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.8.10" - }, - "orig_nbformat": 4, - "kernelspec": { - "name": "python3", - "display_name": "Python 3.8.10 64-bit ('ecnet': conda)" - }, - "interpreter": { - "hash": "b8ddbdeb4e8d258564393fa38a886a93a7bbb414136361bc7c3a5a1c29ceff9e" - } - }, - "nbformat": 4, - "nbformat_minor": 2, "cells": [ { - "cell_type": "code", - "execution_count": 1, + "cell_type": "markdown", + "id": "627af564", "metadata": {}, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Number of samples: 43\nNumber of QSPR descriptors per sample: 1875\n" - ] - } - ], "source": [ - "# First, let's load our experimental cloud point data; we're using PaDEL-Descriptor to generate QSPR descriptors\n", + "# Getting started with ECNet (v4)\n", "\n", - "from ecnet.datasets import load_cp\n", + "This notebook walks through a small cloud-point (CP) workflow with the current public API:\n", + "`load_cp` → train/test split → `select_rfr` → `ECNet.fit` → `save` / `load_model`.\n", + "\n", + "You will leave with a runnable end-to-end pattern and a realistic sense of demo-scale accuracy: short training and a tiny hold-out set are intentional, so metrics and scatter plots illustrate the API rather than a production-quality CP model.\n", + "\n", + "**Requirements:** Python 3.11+, ECNet installed, Java for the default PaDEL backend.\n", + "Epoch counts are kept short for local demos; increase them for stronger fits.\n", + "Commit notebooks **without** stored outputs." + ] + }, + { + "cell_type": "markdown", + "id": "aee8fd7d", + "metadata": {}, + "source": [ + "## Setup\n", "\n", - "dataset = load_cp(as_dataset=True, backend='padel')\n", + "Load the bundled CP dataset with PaDEL descriptors. Prefer `backend=\"padel\"` unless you have a licensed alvaDesc install.\n", "\n", - "print(f'Number of samples: {dataset.desc_vals.shape[0]}')\n", - "print(f'Number of QSPR descriptors per sample: {dataset.desc_vals.shape[1]}')" + "`SEED` pins Python, NumPy, PyTorch, scikit-learn splits, and `select_rfr` so Restart & Run All yields the same metrics and figures on the same machine." ] }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, + "id": "cc4bb581", "metadata": {}, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Number of samples in the training set: 34\nNumber of samples in the testing set: 9\n" - ] - } - ], + "outputs": [], "source": [ - "# Now we create training and testing data subsets; our ANNs regress directly on the training data, and the test set is used to measure blind prediction accuracy\n", + "import random\n", + "import warnings\n", + "from copy import deepcopy\n", "\n", + "import numpy as np\n", + "import torch\n", + "from matplotlib import pyplot as plt\n", + "from sklearn.metrics import median_absolute_error, r2_score\n", "from sklearn.model_selection import train_test_split\n", - "from copy import deepcopy\n", "\n", - "index_train, index_test = train_test_split([i for i in range(len(dataset))], test_size=0.2, random_state=24)\n", + "from ecnet.datasets import load_cp\n", + "\n", + "warnings.filterwarnings(\"ignore\")\n", + "\n", + "SEED = 42\n", + "random.seed(SEED)\n", + "np.random.seed(SEED)\n", + "torch.manual_seed(SEED)\n", + "\n", + "dataset = load_cp(as_dataset=True, backend=\"padel\")\n", + "print(f\"Number of samples: {dataset.desc_vals.shape[0]}\")\n", + "print(f\"Number of QSPR descriptors per sample: {dataset.desc_vals.shape[1]}\")" + ] + }, + { + "cell_type": "markdown", + "id": "a06ee8d6", + "metadata": {}, + "source": [ + "## Train / test split\n", + "\n", + "Hold out 20% of molecules for testing. Feature selection and training use only the training subset so test metrics stay honest." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "33942f9b", + "metadata": {}, + "outputs": [], + "source": [ + "index_train, index_test = train_test_split(\n", + " [i for i in range(len(dataset))],\n", + " test_size=0.2,\n", + " random_state=SEED,\n", + ")\n", "\n", "dataset_train = deepcopy(dataset)\n", "dataset_train.set_index(index_train)\n", - "\n", "dataset_test = deepcopy(dataset)\n", "dataset_test.set_index(index_test)\n", + "print(dataset_train.desc_vals.shape, dataset_test.desc_vals.shape)" + ] + }, + { + "cell_type": "markdown", + "id": "d723fdb9", + "metadata": {}, + "source": [ + "## Feature selection\n", "\n", - "print(f'Number of samples in the training set: {len(dataset_train)}')\n", - "print(f'Number of samples in the testing set: {len(dataset_test)}')" + "`select_rfr` ranks descriptors by random-forest importance and keeps the smallest prefix whose cumulative importance reaches the requested total (here 0.95). The curve should rise quickly at first, then flatten as lower-ranked descriptors add little mass — ending near the 0.95 target." ] }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, + "id": "93b78c8e", "metadata": {}, - "outputs": [ - { - "output_type": "display_data", - "data": { - "text/plain": "
" - }, - "metadata": {} - }, - { - "output_type": "display_data", - "data": { - "text/plain": "
", - "image/svg+xml": "\n\n\n \n \n \n \n 2021-06-30T12:35:48.301674\n image/svg+xml\n \n \n Matplotlib v3.4.2, https://matplotlib.org/\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n", - "image/png": "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\n" - }, - "metadata": { - "needs_background": "light" - } - } - ], + "outputs": [], "source": [ - "# Many QSPR descriptors are not important when predicting cloud point for a database of hydrocarbons and oxygenated compounds; for example, the descriptor counting the number of nitrogen atoms will be zero for all compounds. We will select the descriptors with the highest correlation to cloud point for use as ANN inputs, such that 95% of total correlation (derived from random forest regression) is retained:\n", - "\n", "from ecnet.tasks.feature_selection import select_rfr\n", - "from matplotlib import pyplot as plt\n", - "\n", - "# Note: we select based on the training set, we want the test set to be 100% blind\n", - "desc_idx, desc_imp = select_rfr(dataset_train, total_importance=0.95, n_estimators=50)\n", "\n", + "desc_idx, desc_imp = select_rfr(\n", + " dataset_train,\n", + " total_importance=0.95,\n", + " n_estimators=100,\n", + " n_jobs=1,\n", + " random_state=SEED,\n", + ")\n", "dataset_train.set_desc_index(desc_idx)\n", "dataset_test.set_desc_index(desc_idx)\n", - "\n", - "# Let's graph importance (individual and cumulative sum) for the selected descriptors:\n", - "rank = [i for i in range(len(desc_imp))]\n", - "tot_imp = [0.0]\n", - "for imp in desc_imp:\n", - " tot_imp.append(tot_imp[-1] + imp)\n", - "tot_imp = tot_imp[1:]\n", + "print(dataset_train.desc_vals.shape, dataset_test.desc_vals.shape)\n", + "print(\"Top descriptors:\", [dataset.desc_names[i] for i in desc_idx[:5]])\n", "\n", "plt.clf()\n", - "fig, ax = plt.subplots(constrained_layout=True)\n", - "ax.set_xlabel('Selected descriptor (most-to-least important)')\n", - "ax.set_ylabel('Descriptor importance')\n", - "ax.plot(rank, desc_imp, color='red')\n", - "ax2 = ax.twinx()\n", - "ax2.set_ylabel('Cumulative importance')\n", - "ax2.plot(rank, tot_imp, color='blue')\n", + "plt.xlabel(\"N top descriptors\")\n", + "plt.ylabel(\"Cumulative importance\")\n", + "cum = [sum(desc_imp[: i + 1]) for i in range(len(desc_imp))]\n", + "plt.plot(range(1, len(cum) + 1), cum, color=\"blue\")\n", + "plt.axhline(0.95, color=\"gray\", linestyle=\"--\", linewidth=1)\n", "plt.show()" ] }, { - "cell_type": "code", - "execution_count": 4, + "cell_type": "markdown", + "id": "5c4a5c50", "metadata": {}, - "outputs": [ - { - "output_type": "display_data", - "data": { - "text/plain": "
", - "image/svg+xml": "\n\n\n \n \n \n \n 2021-06-30T12:35:48.486805\n image/svg+xml\n \n \n Matplotlib v3.4.2, https://matplotlib.org/\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n", - "image/png": "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\n" - }, - "metadata": { - "needs_background": "light" - } - } - ], "source": [ - "# We observe that there are only a handful of QSPR descriptors with significant correlation to cloud point; let's visualize the relationship between kinematic viscosity and the descriptor with the highest importance:\n", + "## Train a small network\n", "\n", - "ysi = [dataset_train.target_vals[i][0] for i in range(len(dataset_train))]\n", - "top_desc = [dataset_train.desc_vals[i][0] for i in range(len(dataset_train))]\n", - "\n", - "plt.clf()\n", - "plt.xlabel(f'{dataset_train.desc_names[0]} value')\n", - "plt.ylabel('Experimental cloud point value (deg. C)')\n", - "plt.scatter(top_desc, ysi, color='blue')\n", - "plt.show()" + "Architecture here is deliberately modest (`hidden_dim=128`, two hidden layers) and training is capped at 40 epochs with early stopping. Expect noisy validation loss: the fixed validation split is only ~20% of an already small training set. Re-seed before constructing `ECNet` so weight initialization matches across runs." ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, + "id": "91c19569", "metadata": {}, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Epoch: 0 | Train loss: 3019.848388671875 | Valid loss: 9223372036854775807\n", - "Epoch: 10 | Train loss: 301.50048828125 | Valid loss: 571.7460327148438\n", - "Epoch: 20 | Train loss: 351.8019104003906 | Valid loss: 800.0086669921875\n", - "Epoch: 30 | Train loss: 330.5296325683594 | Valid loss: 539.5159301757812\n", - "Epoch: 40 | Train loss: 264.02142333984375 | Valid loss: 315.75274658203125\n", - "Epoch: 50 | Train loss: 198.6739501953125 | Valid loss: 249.75222778320312\n", - "Epoch: 60 | Train loss: 87.38817596435547 | Valid loss: 125.52989196777344\n", - "Epoch: 70 | Train loss: 59.61891555786133 | Valid loss: 100.54280090332031\n", - "Epoch: 80 | Train loss: 43.34950256347656 | Valid loss: 78.16502380371094\n", - "Epoch: 90 | Train loss: 60.24040985107422 | Valid loss: 91.14484405517578\n" - ] - } - ], + "outputs": [], "source": [ - "# Enough exploration, let's train an ANN to predict kinematic viscosity:\n", + "from math import sqrt\n", "\n", "from ecnet import ECNet\n", "\n", - "# Create an ANN with `n` input neurons (where `n` == number of selected QSPR descriptors), 2 hidden layers with 256 neurons each, and one output neuron (corresponding to yield sooting index)\n", - "model = ECNet(dataset_train.desc_vals.shape[1], 1, 256, 2)\n", - "# arguments follow [input dim, output dim, hidden dim, n hidden]\n", - "\n", - "# Train the ANN using training dataset, with a random 20% of the dataset used for validation every epoch:\n", + "torch.manual_seed(SEED)\n", + "model = ECNet(dataset_train.desc_vals.shape[1], 1, 128, 2)\n", "train_loss, valid_loss = model.fit(\n", - " dataset=dataset_train, valid_size=0.2, verbose=10,\n", - " patience=32, epochs=300, random_state=None, shuffle=True,\n", - " lr=0.005\n", + " dataset=dataset_train,\n", + " valid_size=0.2,\n", + " verbose=10,\n", + " patience=16,\n", + " epochs=40,\n", + " random_state=SEED,\n", ")" ] }, { - "cell_type": "code", - "execution_count": 6, + "cell_type": "markdown", + "id": "d0c0e41e", "metadata": {}, - "outputs": [ - { - "output_type": "display_data", - "data": { - "text/plain": "
", - "image/svg+xml": "\n\n\n \n \n \n \n 2021-06-30T12:35:48.955909\n image/svg+xml\n \n \n Matplotlib v3.4.2, https://matplotlib.org/\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n", - "image/png": "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\n" - }, - "metadata": { - "needs_background": "light" - } - } - ], "source": [ - "# We can visualize the training set's loss over training, as well as the validation subset's loss:\n", + "### Learning curves\n", "\n", - "from math import sqrt\n", + "Plot $\\sqrt{\\mathrm{MSE}}$ so the vertical scale is closer to CP units (°C). Both curves should trend downward overall; validation will often oscillate more than training because so few molecules are held out each epoch." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d0c9aad5", + "metadata": {}, + "outputs": [], + "source": [ + "train_curve = [sqrt(float(loss)) for loss in train_loss]\n", + "valid_curve = [sqrt(float(loss)) for loss in valid_loss]\n", + "epochs = list(range(len(train_curve)))\n", "\n", - "train_loss = [sqrt(l) for l in train_loss][5:]\n", - "valid_loss = [sqrt(l) for l in valid_loss][5:]\n", - "epoch = [i for i in range(len(train_loss))]\n", "plt.clf()\n", - "plt.xlabel('Epochs')\n", - "plt.ylabel('Sqrt(Loss)')\n", - "plt.plot(epoch, train_loss, color='blue', label='Training Loss')\n", - "plt.plot(epoch, valid_loss, color='red', label='Validation Loss')\n", - "plt.legend(loc='upper right')\n", + "plt.xlabel(\"Epochs\")\n", + "plt.ylabel(\"Sqrt(Loss)\")\n", + "plt.plot(epochs, train_curve, color=\"blue\", label=\"Training\")\n", + "plt.plot(epochs, valid_curve, color=\"red\", label=\"Validation\")\n", + "plt.legend(loc=\"upper right\")\n", "plt.show()" ] }, { - "cell_type": "code", - "execution_count": 7, + "cell_type": "markdown", + "id": "de804b68", "metadata": {}, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Training median absolute error: 4.776594161987305\nTraining r-squared coefficient: 0.8675852185693186\nTesting median absolute error: 9.086380004882812\nTesting r-squared coefficient: 0.8877325994065388\n" - ] - } - ], "source": [ - "# Let's calculate median absolute error and r-squared coefficient for each dataset:\n", + "## Results\n", "\n", - "from sklearn.metrics import median_absolute_error, r2_score\n", + "Report median absolute error and $R^2$ on the training subset and the held-out test molecules. With short training you should see a clear experimental–predicted trend on the parity plot, but not a tight diagonal — and test error will usually exceed train error.\n", "\n", - "y_hat_train = model(dataset_train.desc_vals).detach().numpy()\n", - "y_train = dataset_train.target_vals\n", - "train_mae = median_absolute_error(y_hat_train, y_train)\n", - "train_r2 = r2_score(y_hat_train, y_train)\n", - "y_hat_test = model(dataset_test.desc_vals).detach().numpy()\n", - "y_test = dataset_test.target_vals\n", - "test_mae = median_absolute_error(y_hat_test, y_test)\n", - "test_r2 = r2_score(y_hat_test, y_test)\n", - "print(f'Training median absolute error: {train_mae}')\n", - "print(f'Training r-squared coefficient: {train_r2}')\n", - "print(f'Testing median absolute error: {test_mae}')\n", - "print(f'Testing r-squared coefficient: {test_r2}')" + "The dashed line is $y = x$ (perfect prediction). Points above it are over-predictions; points below are under-predictions." ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, + "id": "c296200f", "metadata": {}, - "outputs": [ - { - "output_type": "display_data", - "data": { - "text/plain": "
", - "image/svg+xml": "\n\n\n \n \n \n \n 2021-06-30T12:35:49.142649\n image/svg+xml\n \n \n Matplotlib v3.4.2, https://matplotlib.org/\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n", - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAYUAAAEICAYAAACwDehOAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjQuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8rg+JYAAAACXBIWXMAAAsTAAALEwEAmpwYAAAtlElEQVR4nO3de5xVdb3/8debiyCXvCAaR2IGyzAUHATxegzvlZimZXomoyzR6qRZ/rwc6qd2Iq1MyzpqqCUd55TmT3+oeSohzbv+wBsKx1QcEEMcURG8cZnP74+1ZhiG2Xv2zL7O7Pfz8ZjH3uu799rrM4thffb6XhURmJmZAfQpdwBmZlY5nBTMzKyVk4KZmbVyUjAzs1ZOCmZm1spJwczMWhUtKUj6taRXJT3dpmx7SXdJei593C4tl6QrJD0v6SlJexUrLjMzy0zFGqcg6SBgLfDbiNgjLfsx8HpEXCLpPGC7iDhX0qeAbwKfAvYBfh4R+3R2jB122CFqa2uLEr+ZWW+1YMGC1yJieEev9SvWQSPiXkm17YqPAaakz2cD9wDnpuW/jSRDPSxpW0kjImJFtmPU1tYyf/78gsZtZtbbSVqa6bVStyns1OZC/wqwU/p8Z+ClNu9bnpaZmVkJla2hOb0r6HLdlaTpkuZLmt/U1FSEyMzMqlepk8JKSSMA0sdX0/KXgQ+1ed/ItGwLETErIiZFxKThwzusEjMzs24qWptCBrcB04BL0sc5bcr/VdLvSRqaV3fWnpDJ+vXrWb58Oe+9914h4rUcDRw4kJEjR9K/f/9yh2JmeShaUpD0O5JG5R0kLQcuIEkGN0n6CrAUOCF9+50kPY+eB94Bvtzd4y5fvpyhQ4dSW1uLpDx+A8tVRLBq1SqWL1/O6NGjyx2OmeWhaNVHEXFSRIyIiP4RMTIirouIVRFxaETsGhGHRcTr6XsjIr4RER+OiHER0e0uRe+99x7Dhg1zQighSQwbNsx3Z1axGhqgthb69EkeGxrKHVHlKnX1UUk4IZSez7lVqoYGmD4d3nkn2V66NNkGqK8vX1yVytNcFNiqVauoq6ujrq6OD37wg+y8886t2+vWrcu67/z58znjjDM6Pcb+++9fkFjfeecd6uvrGTduHHvssQcHHngga9euzbrPD3/4w4Ic26xUZszYlBBavPNOUm5bKtqI5lKYNGlStB+8tnjxYj72sY+VKaLNXXjhhQwZMoSzzz67tWzDhg3061cZN2gXX3wxTU1NXHbZZQA8++yz1NbWMmDAgIz7DBkyJGPiqKRzb9aiTx/o6DInQXNz6eOpBJIWRMSkjl7znUIJfOlLX+L0009nn3324ZxzzuHRRx9lv/32Y8KECey///48++yzANxzzz1MnToVSBLKKaecwpQpU9hll1244oorWj9vyJAhre+fMmUKn/3sZ9ltt92or6+nJcnfeeed7LbbbkycOJEzzjij9XPbWrFiBTvvvGmM4JgxY1oTwg033MDkyZOpq6vjtNNOY+PGjZx33nm8++671NXVUe/7bushRo3qWnmlK3r7SET02J+JEydGe4sWLdqiLJsbboioqYmQkscbbujS7lldcMEF8ZOf/CSmTZsWRx11VGzYsCEiIlavXh3r16+PiIi77rorjjvuuIiIuPvuu+Ooo45q3Xe//faL9957L5qammL77bePdevWRUTE4MGDW9//gQ98IF566aXYuHFj7LvvvnHffffFu+++GyNHjowlS5ZERMSJJ57Y+rltPf744zF8+PDYd999Y8aMGfH3v/89IpJzOHXq1Nbjfe1rX4vZs2dvduyOdPXcm5XCDTdEDBoUkdwvJD+DBhX2/3qpFOp3AeZHhutqZdRjlEkpG6A+97nP0bdvXwBWr17NtGnTeO6555DE+vXrO9znqKOOYsCAAQwYMIAdd9yRlStXMnLkyM3eM3ny5Nayuro6GhsbGTJkCLvssktr99CTTjqJWbNmbfH5dXV1LFmyhL/85S/MnTuXvffem4ceeoh58+axYMEC9t57bwDeffdddtxxx4KdC7NSavm/PGMGLFuW3CHMnNkzG5mztY8U6vep6qRQihPcYvDgwa3Pv/e973HwwQdz66230tjYyJQpUzrcp23dft++fdmwYUO33pPNkCFDOO644zjuuOPo06cPd955J1tttRXTpk3j4osv7tJnmVWq+vqemQTaW7asa+XdUdVtCqU4wR1ZvXp1a13+9ddfX/DPHzNmDEuWLKGxsRGAG2+8scP3PfDAA7zxxhsArFu3jkWLFlFTU8Ohhx7KzTffzKuvJrOQvP766yxdmkyq2L9//4x3NmZVq0QDIUrRPlLVSaFcDVDnnHMO559/PhMmTOjyN/tcbL311lx55ZV84hOfYOLEiQwdOpRtttlmi/e98MIL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\n" - }, - "metadata": { - "needs_background": "light" - } - } - ], + "outputs": [], "source": [ - "# Now we can visually compare predicted values to experimental values:\n", + "y_hat_train = model(dataset_train.desc_vals).detach().numpy()\n", + "y_train = dataset_train.target_vals.detach().numpy()\n", + "y_hat_test = model(dataset_test.desc_vals).detach().numpy()\n", + "y_test = dataset_test.target_vals.detach().numpy()\n", + "\n", + "print(\"Train MAE:\", median_absolute_error(y_train, y_hat_train))\n", + "print(\"Train R^2:\", r2_score(y_train, y_hat_train))\n", + "print(\"Test MAE:\", median_absolute_error(y_test, y_hat_test))\n", + "print(\"Test R^2:\", r2_score(y_test, y_hat_test))\n", + "\n", + "lo = float(min(y_train.min(), y_hat_train.min(), y_test.min(), y_hat_test.min()))\n", + "hi = float(max(y_train.max(), y_hat_train.max(), y_test.max(), y_hat_test.max()))\n", "\n", "plt.clf()\n", - "plt.xlabel('Experimental CP Value (deg. C)')\n", - "plt.ylabel('Predicted CP Value (deg. C)')\n", - "plt.scatter(y_train, y_hat_train, color='blue', label='Training Set')\n", - "plt.scatter(y_test, y_hat_test, color='red', label='Testing Set')\n", - "plt.legend(loc='upper left')\n", + "plt.xlabel(\"Experimental CP (deg. C)\")\n", + "plt.ylabel(\"Predicted CP (deg. C)\")\n", + "plt.plot([lo, hi], [lo, hi], \"k--\", linewidth=1, label=\"Ideal\")\n", + "plt.scatter(y_train, y_hat_train, color=\"blue\", label=\"Training\")\n", + "plt.scatter(y_test, y_hat_test, color=\"red\", label=\"Testing\")\n", + "plt.gca().set_aspect(\"equal\", adjustable=\"box\")\n", + "plt.legend(loc=\"upper left\")\n", "plt.show()" ] }, { - "cell_type": "code", - "execution_count": 9, + "cell_type": "markdown", + "id": "282519d2", "metadata": {}, - "outputs": [], "source": [ - "# Let's save our model for later use:\n", + "## Persist and reload\n", "\n", - "model.save('cp_model.pt')" + "`ECNet.save` writes an `ecnet-state-v1` checkpoint. Reloading with `load_model` should reproduce the same test MAE." ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, + "id": "fda7fa18", "metadata": {}, - "outputs": [ - { - "output_type": "stream", - "name": "stdout", - "text": [ - "Training median absolute error: 4.776594161987305\nTraining r-squared coefficient: 0.8675852185693186\nTesting median absolute error: 9.086380004882812\nTesting r-squared coefficient: 0.8877325994065388\n" - ] - }, - { - "output_type": "display_data", - "data": { - "text/plain": "
", - "image/svg+xml": "\n\n\n \n \n \n \n 2021-06-30T12:35:49.338191\n image/svg+xml\n \n \n Matplotlib v3.4.2, https://matplotlib.org/\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n", - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAYUAAAEICAYAAACwDehOAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjQuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8rg+JYAAAACXBIWXMAAAsTAAALEwEAmpwYAAAtlElEQVR4nO3de5xVdb3/8debiyCXvCAaR2IGyzAUHATxegzvlZimZXomoyzR6qRZ/rwc6qd2Iq1MyzpqqCUd55TmT3+oeSohzbv+wBsKx1QcEEMcURG8cZnP74+1ZhiG2Xv2zL7O7Pfz8ZjH3uu799rrM4thffb6XhURmJmZAfQpdwBmZlY5nBTMzKyVk4KZmbVyUjAzs1ZOCmZm1spJwczMWhUtKUj6taRXJT3dpmx7SXdJei593C4tl6QrJD0v6SlJexUrLjMzy0zFGqcg6SBgLfDbiNgjLfsx8HpEXCLpPGC7iDhX0qeAbwKfAvYBfh4R+3R2jB122CFqa2uLEr+ZWW+1YMGC1yJieEev9SvWQSPiXkm17YqPAaakz2cD9wDnpuW/jSRDPSxpW0kjImJFtmPU1tYyf/78gsZtZtbbSVqa6bVStyns1OZC/wqwU/p8Z+ClNu9bnpaZmVkJla2hOb0r6HLdlaTpkuZLmt/U1FSEyMzMqlepk8JKSSMA0sdX0/KXgQ+1ed/ItGwLETErIiZFxKThwzusEjMzs24qWptCBrcB04BL0sc5bcr/VdLvSRqaV3fWnpDJ+vXrWb58Oe+9914h4rUcDRw4kJEjR9K/f/9yh2JmeShaUpD0O5JG5R0kLQcuIEkGN0n6CrAUOCF9+50kPY+eB94Bvtzd4y5fvpyhQ4dSW1uLpDx+A8tVRLBq1SqWL1/O6NGjyx2OmeWhaNVHEXFSRIyIiP4RMTIirouIVRFxaETsGhGHRcTr6XsjIr4RER+OiHER0e0uRe+99x7Dhg1zQighSQwbNsx3Z1axGhqgthb69EkeGxrKHVHlKnX1UUk4IZSez7lVqoYGmD4d3nkn2V66NNkGqK8vX1yVytNcFNiqVauoq6ujrq6OD37wg+y8886t2+vWrcu67/z58znjjDM6Pcb+++9fkFjfeecd6uvrGTduHHvssQcHHngga9euzbrPD3/4w4Ic26xUZszYlBBavPNOUm5bKtqI5lKYNGlStB+8tnjxYj72sY+VKaLNXXjhhQwZMoSzzz67tWzDhg3061cZN2gXX3wxTU1NXHbZZQA8++yz1NbWMmDAgIz7DBkyJGPiqKRzb9aiTx/o6DInQXNz6eOpBJIWRMSkjl7znUIJfOlLX+L0009nn3324ZxzzuHRRx9lv/32Y8KECey///48++yzANxzzz1MnToVSBLKKaecwpQpU9hll1244oorWj9vyJAhre+fMmUKn/3sZ9ltt92or6+nJcnfeeed7LbbbkycOJEzzjij9XPbWrFiBTvvvGmM4JgxY1oTwg033MDkyZOpq6vjtNNOY+PGjZx33nm8++671NXVUe/7bushRo3qWnmlK3r7SET02J+JEydGe4sWLdqiLJsbboioqYmQkscbbujS7lldcMEF8ZOf/CSmTZsWRx11VGzYsCEiIlavXh3r16+PiIi77rorjjvuuIiIuPvuu+Ooo45q3Xe//faL9957L5qammL77bePdevWRUTE4MGDW9//gQ98IF566aXYuHFj7LvvvnHffffFu+++GyNHjowlS5ZERMSJJ57Y+rltPf744zF8+PDYd999Y8aMGfH3v/89IpJzOHXq1Nbjfe1rX4vZs2dvduyOdPXcm5XCDTdEDBoUkdwvJD+DBhX2/3qpFOp3AeZHhutqZdRjlEkpG6A+97nP0bdvXwBWr17NtGnTeO6555DE+vXrO9znqKOOYsCAAQwYMIAdd9yRlStXMnLkyM3eM3ny5Nayuro6GhsbGTJkCLvssktr99CTTjqJWbNmbfH5dXV1LFmyhL/85S/MnTuXvffem4ceeoh58+axYMEC9t57bwDeffdddtxxx4KdC7NSavm/PGMGLFuW3CHMnNkzG5mztY8U6vep6qRQihPcYvDgwa3Pv/e973HwwQdz66230tjYyJQpUzrcp23dft++fdmwYUO33pPNkCFDOO644zjuuOPo06cPd955J1tttRXTpk3j4osv7tJnmVWq+vqemQTaW7asa+XdUdVtCqU4wR1ZvXp1a13+9ddfX/DPHzNmDEuWLKGxsRGAG2+8scP3PfDAA7zxxhsArFu3jkWLFlFTU8Ohhx7KzTffzKuvJrOQvP766yxdmkyq2L9//4x3NmZVq0QDIUrRPlLVSaFcDVDnnHMO559/PhMmTOjyN/tcbL311lx55ZV84hOfYOLEiQwdOpRtttlmi/e98MIL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\n" - }, - "metadata": { - "needs_background": "light" - } - } - ], + "outputs": [], "source": [ - "# And test to make sure we can recall it:\n", - "\n", "from ecnet.model import load_model\n", "\n", - "model_2 = load_model('cp_model.pt')\n", - "\n", - "y_hat_train = model_2(dataset_train.desc_vals).detach().numpy()\n", - "y_train = dataset_train.target_vals\n", - "train_mae = median_absolute_error(y_hat_train, y_train)\n", - "train_r2 = r2_score(y_hat_train, y_train)\n", - "y_hat_test = model_2(dataset_test.desc_vals).detach().numpy()\n", - "y_test = dataset_test.target_vals\n", - "test_mae = median_absolute_error(y_hat_test, y_test)\n", - "test_r2 = r2_score(y_hat_test, y_test)\n", - "print(f'Training median absolute error: {train_mae}')\n", - "print(f'Training r-squared coefficient: {train_r2}')\n", - "print(f'Testing median absolute error: {test_mae}')\n", - "print(f'Testing r-squared coefficient: {test_r2}')\n", + "model.save(\"cp_model.pt\")\n", + "restored = load_model(\"cp_model.pt\")\n", + "y_check = restored(dataset_test.desc_vals).detach().numpy()\n", + "print(\"Reload test MAE:\", median_absolute_error(y_test, y_check))" + ] + }, + { + "cell_type": "markdown", + "id": "883c2b37", + "metadata": {}, + "source": [ + "## Takeaways\n", "\n", - "plt.clf()\n", - "plt.xlabel('Experimental CP Value (deg. C)')\n", - "plt.ylabel('Predicted CP Value (deg. C)')\n", - "plt.scatter(y_train, y_hat_train, color='blue', label='Training Set')\n", - "plt.scatter(y_test, y_hat_test, color='red', label='Testing Set')\n", - "plt.legend(loc='upper left')\n", - "plt.show()" + "- Use `backend=\"padel\"` for the default descriptor path; alvaDesc is optional.\n", + "- Run `select_rfr` on the **training** split only, then apply the same descriptor index to the test set.\n", + "- Short `epochs` / small $N$ yield noisy validation curves and modest $R^2$; raise epochs (and data) for stronger fits.\n", + "- Keep `SEED` (and `random_state=SEED` / `torch.manual_seed(SEED)`) for bit-stable demo reruns on one machine.\n", + "- Parity plots need an identity line and equal axes to judge bias; MAE/$R^2$ use `y_true` then `y_pred`.\n", + "- `save` / `load_model` round-trip should leave predictions unchanged." ] }, { - "cell_type": "code", - "execution_count": null, + "cell_type": "markdown", + "id": "a8a1e82f", "metadata": {}, - "outputs": [], - "source": [] + "source": [ + "## Further reading\n", + "\n", + "- [Quickstart](https://ecnet.readthedocs.io/en/latest/quickstart.html)\n", + "- [API reference](https://ecnet.readthedocs.io/en/latest/api.html)\n", + "- Related examples: `example.ipynb`, `example_multiprop.ipynb`" + ] } - ] -} \ No newline at end of file + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 9340208..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,14 +0,0 @@ -site_name: ECNet Documentation -nav: - - Installation: index.md - - API Documentation: - - ecnet.model: api_model.md - - ecnet.datasets: api_datasets.md - - ecnet.tasks: api_tasks.md - - ecnet.blends: api_blends.md - - ecnet.callbacks: api_callbacks.md -theme: - name: "material" -plugins: - - search - - mkdocstrings \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 328e479..3bac519 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,19 +6,44 @@ build-backend = "setuptools.build_meta" include-package-data = true [tool.setuptools.packages.find] +where = ["src"] exclude = ["databases*", "paper*"] [tool.setuptools.package-data] -"*" = ["*.smiles", "*.target"] +"ecnet" = ["**/*.smiles", "**/*.target"] [tool.pytest.ini_options] +pythonpath = ["."] filterwarnings = [ "ignore::DeprecationWarning", ] +markers = [ + "integration: requires external services, licensed tools, or large data", + "slow: long-running tests; may be deselected in fast local runs", +] + +[tool.ruff] +line-length = 88 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [ + "E501", # legacy long lines; tighten gradually +] + +[tool.ruff.lint.per-file-ignores] +# Re-export modules intentionally import public names for package surface. +"**/__init__.py" = ["F401"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" [project] name = "ecnet" -version = "4.1.4" +version = "4.1.5" authors = [ { name="Travis Kessler", email="travis.j.kessler@gmail.com" }, ] @@ -26,18 +51,36 @@ description = "Fuel property prediction using QSPR descriptors" readme = "README.md" requires-python = ">=3.11" dependencies = [ - "torch==2.4.0", - "scikit-learn==1.5.1", - "padelpy==0.1.16", - "alvadescpy==0.1.3", - "ecabc==3.0.1" + "torch>=2.4.0,<2.6", + "scikit-learn>=1.5.1,<2", + "padelpy>=0.1.16,<0.2", + "alvadescpy>=0.1.3,<0.2", + "ecabc>=3.0.1,<4", ] classifiers = [ "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-cov>=6", + "ruff>=0.8", + "pre-commit>=4", + "build>=1.2", + "pip-audit>=2.7", +] +docs = [ + "sphinx>=7", + "furo>=2024.8", + "sphinx-autodoc-typehints>=2", + "sphinx-copybutton>=0.5", + "myst-parser>=3", +] + [project.urls] "Homepage" = "https://github.com/ecrl/ecnet" -"Bug Tracker" = "https://github.com/ecrl/ecnet/issues" \ No newline at end of file +"Bug Tracker" = "https://github.com/ecrl/ecnet/issues" diff --git a/src/ecnet/__init__.py b/src/ecnet/__init__.py new file mode 100644 index 0000000..a2ec9db --- /dev/null +++ b/src/ecnet/__init__.py @@ -0,0 +1,17 @@ +from importlib.metadata import version +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .model import ECNet as ECNet + +__version__ = version("ecnet") + +__all__ = ["ECNet", "__version__"] + + +def __getattr__(name: str): + if name == "ECNet": + from .model import ECNet as _ECNet + + return _ECNet + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/ecnet/blends/__init__.py b/src/ecnet/blends/__init__.py new file mode 100644 index 0000000..37f5358 --- /dev/null +++ b/src/ecnet/blends/__init__.py @@ -0,0 +1,19 @@ +from .equations import exponential_blend_err, kv_error, linear_blend_err +from .predict import ( + cetane_number, + cloud_point, + kinematic_viscosity, + lower_heating_value, + yield_sooting_index, +) + +__all__ = [ + "cetane_number", + "cloud_point", + "exponential_blend_err", + "kinematic_viscosity", + "kv_error", + "linear_blend_err", + "lower_heating_value", + "yield_sooting_index", +] diff --git a/ecnet/blends/equations.py b/src/ecnet/blends/equations.py similarity index 63% rename from ecnet/blends/equations.py rename to src/ecnet/blends/equations.py index 6034e77..76abf81 100644 --- a/ecnet/blends/equations.py +++ b/src/ecnet/blends/equations.py @@ -1,6 +1,7 @@ r"""Helper equations for functions in .predict.py""" -from typing import List + from math import sqrt +from typing import List def celsius_to_rankine(temp: float) -> float: @@ -33,39 +34,43 @@ def linear_blend_ave(values: List[float], proportions: List[float]) -> float: weighted_ave = 0 for idx, proportion in enumerate(proportions): - weighted_ave += (proportion * values[idx]) + weighted_ave += proportion * values[idx] return weighted_ave def linear_blend_err(errors: List[float], proportions: List[float]) -> float: """ - Calculates the linear combination of multiple errors given discrete - proportions for each value - - $$ f = aA \\rightarrow error^2 = a^2 * error_A^2 \\rightarrow error^2 \\rightarrow \\sum $$ - - Args: - errors (list[float]): list of error values - proportions (list[float]): proportions of each value in `values`; should sum - to 1; len(proportions) == len(errors) - - Returns: - float: weighted linear error + Propagate component errors for a linear blend (root-sum-square). + + Parameters + ---------- + errors : list[float] + Component error values. + proportions : list[float] + Component proportions; should sum to 1. + + Returns + ------- + float + Weighted linear error. """ total_error = 0.0 for idx, err in enumerate(errors): - total_error += (err * proportions[idx])**2 + total_error += (err * proportions[idx]) ** 2 return sqrt(total_error) -def exponential_blend_err(values: List[float], result: float, errors: List[float], - proportions: List[float], a: float, b: float) -> float: +def exponential_blend_err( + values: List[float], + result: float, + errors: List[float], + proportions: List[float], + a: float, + b: float, +) -> float: """ - Calculates the error of a blend whose equation is defined as $$ f = aA^b $$ - - $$ f = aA^b \\rightarrow err_f^2 = (abA^{b-1}err_A)^2 = (fberr_A / A)^2 - \\rightarrow \\sum $$ + Calculates the error of a blend whose equation is of the form f = a * A**b. Args: values (list[float]): predicted values @@ -81,20 +86,21 @@ def exponential_blend_err(values: List[float], result: float, errors: List[float total_error = 0.0 for idx, err in enumerate(errors): - total_error += (((result * b * err) / values[idx]) * proportions[idx])**2 + total_error += (((result * b * err) / values[idx]) * proportions[idx]) ** 2 return sqrt(total_error) -def kv_error(values: List[float], errors: List[float], proportions: List[float]) -> float: +def kv_error( + values: List[float], errors: List[float], proportions: List[float] +) -> float: """ - Calculates the error of a KV blend whose equation is in the form $$ f=aln(bA) $$ + Calculate kinematic-viscosity blend error for f = a * ln(b * A). - $$ f = aln(bA) \\rightarrow err_f^2 = (a * err_A / A)^2 \\rightarrow \\sum $$ - for KV, $$a = 1.0, b = 2000$$ + For the implemented KV rule, a = 1.0 and b = 2000. Args: values (list[float]): predicted values - errors (list[float]): errors for predicted values in `values` + errors (list[float]): errors for predicted values in values proportions (list[float]): contribution of each value to blend; sum = 1 Returns: @@ -103,7 +109,7 @@ def kv_error(values: List[float], errors: List[float], proportions: List[float]) total_error = 0.0 for idx, err in enumerate(errors): - total_error += (proportions[idx] * err / values[idx])**2 + total_error += (proportions[idx] * err / values[idx]) ** 2 return sqrt(total_error) diff --git a/ecnet/blends/predict.py b/src/ecnet/blends/predict.py similarity index 92% rename from ecnet/blends/predict.py rename to src/ecnet/blends/predict.py index 9cacf3e..48d0e52 100644 --- a/ecnet/blends/predict.py +++ b/src/ecnet/blends/predict.py @@ -1,8 +1,9 @@ r"""Functions for predicting blend properties""" + +from math import exp, log from typing import List -from math import log, exp -from .equations import linear_blend_ave, celsius_to_rankine, rankine_to_celsius +from .equations import celsius_to_rankine, linear_blend_ave, rankine_to_celsius def cetane_number(values: List[float], vol_fractions: List[float]) -> float: @@ -44,8 +45,8 @@ def cloud_point(values: List[float], vol_fractions: List[float]) -> float: cp_sum = 0.0 for idx, val in enumerate(values): - cp_sum += (vol_fractions[idx] * celsius_to_rankine(val)**13.45) - return rankine_to_celsius(cp_sum**(1 / 13.45)) + cp_sum += vol_fractions[idx] * celsius_to_rankine(val) ** 13.45 + return rankine_to_celsius(cp_sum ** (1 / 13.45)) def kinematic_viscosity(values: List[float], vol_fractions: List[float]) -> float: @@ -68,7 +69,7 @@ def kinematic_viscosity(values: List[float], vol_fractions: List[float]) -> floa kv_sum = 0.0 for idx, val in enumerate(values): - kv_sum += (vol_fractions[idx] / log(2000 * val)) + kv_sum += vol_fractions[idx] / log(2000 * val) return exp(1 / kv_sum) / 2000 diff --git a/ecnet/callbacks.py b/src/ecnet/callbacks.py similarity index 79% rename from ecnet/callbacks.py rename to src/ecnet/callbacks.py index dc226b4..57c0c49 100644 --- a/ecnet/callbacks.py +++ b/src/ecnet/callbacks.py @@ -1,4 +1,5 @@ r"""Training callback objects/functions""" + import sys @@ -91,21 +92,41 @@ class Callback(object): Base Callback object """ - def __init__(self): pass - def on_train_begin(self): return True - def on_train_end(self): return True - def on_epoch_begin(self, epoch): return True - def on_epoch_end(self, epoch): return True - def on_batch_begin(self, batch): return True - def on_batch_end(self, batch): return True - def on_loss_begin(self, batch): return True - def on_loss_end(self, batch): return True - def on_step_begin(self, batch): return True - def on_step_end(self, batch): return True + def __init__(self): + pass + def on_train_begin(self): + return True -class LRDecayLinear(Callback): + def on_train_end(self): + return True + + def on_epoch_begin(self, epoch): + return True + + def on_epoch_end(self, epoch): + return True + + def on_batch_begin(self, batch): + return True + + def on_batch_end(self, batch): + return True + + def on_loss_begin(self, batch): + return True + def on_loss_end(self, batch): + return True + + def on_step_begin(self, batch): + return True + + def on_step_end(self, batch): + return True + + +class LRDecayLinear(Callback): def __init__(self, init_lr: float, decay_rate: float, optimizer): """ Linear learning rate decay @@ -130,12 +151,11 @@ def on_epoch_begin(self, epoch: int) -> bool: if lr == 0.0: return False for g in self.optimizer.param_groups: - g['lr'] = lr + g["lr"] = lr return True class Validator(Callback): - def __init__(self, loader, model, eval_iter: int, patience: int): """ Periodic validation using training data subset @@ -155,7 +175,9 @@ def __init__(self, loader, model, eval_iter: int, patience: int): self._best_loss = sys.maxsize self._most_recent_loss = sys.maxsize self._epoch_since_best = 0 - self.best_state = model.state_dict() + self.best_state = { + key: value.detach().clone() for key, value in model.state_dict().items() + } self._patience = patience def on_epoch_end(self, epoch: int) -> bool: @@ -168,15 +190,19 @@ def on_epoch_end(self, epoch: int) -> bool: return True valid_loss = 0.0 for batch in self.loader: - v_pred = self.model(batch['desc_vals']) - v_target = batch['target_val'] + v_pred = self.model(batch["desc_vals"]) + v_target = batch["target_val"] v_loss = self.model.loss(v_pred, v_target) - valid_loss += v_loss * len(batch['target_val']) + valid_loss += v_loss * len(batch["target_val"]) valid_loss /= len(self.loader.dataset) self._most_recent_loss = valid_loss if valid_loss < self._best_loss: self._best_loss = valid_loss - self.best_state = self.model.state_dict() + # Clone tensors so later training steps do not mutate the checkpoint. + self.best_state = { + key: value.detach().clone() + for key, value in self.model.state_dict().items() + } self._epoch_since_best = 0 return True self._epoch_since_best += self._ei diff --git a/src/ecnet/datasets/__init__.py b/src/ecnet/datasets/__init__.py new file mode 100644 index 0000000..547804f --- /dev/null +++ b/src/ecnet/datasets/__init__.py @@ -0,0 +1,29 @@ +from .load_data import ( + load_bp, + load_cn, + load_cp, + load_kv, + load_lhv, + load_mon, + load_mp, + load_pp, + load_ron, + load_ysi, +) +from .structs import QSPRDataset, QSPRDatasetFromFile, QSPRDatasetFromValues + +__all__ = [ + "QSPRDataset", + "QSPRDatasetFromFile", + "QSPRDatasetFromValues", + "load_bp", + "load_cn", + "load_cp", + "load_kv", + "load_lhv", + "load_mon", + "load_mp", + "load_pp", + "load_ron", + "load_ysi", +] diff --git a/src/ecnet/datasets/data/README.md b/src/ecnet/datasets/data/README.md new file mode 100644 index 0000000..fa1e5b0 --- /dev/null +++ b/src/ecnet/datasets/data/README.md @@ -0,0 +1,8 @@ +# Bundled property files + +This directory contains the installable `.smiles` / `.target` pairs used by +`ecnet.datasets.load_*`. + +Dataset cards (scope, units, counts, license notes) live in the Sphinx docs: + +- `docs/source/data.rst` (*Bundled property datasets*) diff --git a/ecnet/datasets/data/bp.smiles b/src/ecnet/datasets/data/bp.smiles similarity index 99% rename from ecnet/datasets/data/bp.smiles rename to src/ecnet/datasets/data/bp.smiles index c5dfed5..8e68170 100644 --- a/ecnet/datasets/data/bp.smiles +++ b/src/ecnet/datasets/data/bp.smiles @@ -202,4 +202,4 @@ CCCCC(C)CC CCCCC(CC)CO CCCC(C)C(=O)C CC(C)(C)OCC(CO)OC(C)(C)C -C(CO)O \ No newline at end of file +C(CO)O diff --git a/ecnet/datasets/data/bp.target b/src/ecnet/datasets/data/bp.target similarity index 99% rename from ecnet/datasets/data/bp.target rename to src/ecnet/datasets/data/bp.target index dd0f939..c133e60 100644 --- a/ecnet/datasets/data/bp.target +++ b/src/ecnet/datasets/data/bp.target @@ -202,4 +202,4 @@ 184.3 137 223 -197.3 \ No newline at end of file +197.3 diff --git a/ecnet/datasets/data/cn.smiles b/src/ecnet/datasets/data/cn.smiles similarity index 99% rename from ecnet/datasets/data/cn.smiles rename to src/ecnet/datasets/data/cn.smiles index 4238be7..b82b084 100644 --- a/ecnet/datasets/data/cn.smiles +++ b/src/ecnet/datasets/data/cn.smiles @@ -457,4 +457,4 @@ CCCCCCOC(=O)CCCCC CCCCCOC(=O)C CCOCC1=CC=CO1 CCOCC1CCCO1 -CCCCOC(=O)C=CC(=O)OCCCC \ No newline at end of file +CCCCOC(=O)C=CC(=O)OCCCC diff --git a/ecnet/datasets/data/cn.target b/src/ecnet/datasets/data/cn.target similarity index 99% rename from ecnet/datasets/data/cn.target rename to src/ecnet/datasets/data/cn.target index 98e1947..ddf4dfb 100644 --- a/ecnet/datasets/data/cn.target +++ b/src/ecnet/datasets/data/cn.target @@ -457,4 +457,4 @@ 23.6 18.4 78.9 -23 \ No newline at end of file +23 diff --git a/ecnet/datasets/data/cp.smiles b/src/ecnet/datasets/data/cp.smiles similarity index 96% rename from ecnet/datasets/data/cp.smiles rename to src/ecnet/datasets/data/cp.smiles index c900340..edaa078 100644 --- a/ecnet/datasets/data/cp.smiles +++ b/src/ecnet/datasets/data/cp.smiles @@ -40,4 +40,4 @@ CCCCCCCCCCCCCCCCOCCOCCOCCOCCOCCOCCO CCCCCCCCCCCCCCCCOCCOCCOCCOCCOCCOCCOCCO CCCCCCCCCCCCCCCCOCCOCCOCCOCCOCCOCCOCCOCCO CCCCCCCCCCCCCCCCOCCOCCOCCOCCOCCOCCOCCOCCOCCO -CCCCCCCCCCCCCCCCOCCOCCOCCOCCOCCOCCOCCOCCOCCOCCOCCOCCO \ No newline at end of file +CCCCCCCCCCCCCCCCOCCOCCOCCOCCOCCOCCOCCOCCOCCOCCOCCOCCO diff --git a/ecnet/datasets/data/cp.target b/src/ecnet/datasets/data/cp.target similarity index 98% rename from ecnet/datasets/data/cp.target rename to src/ecnet/datasets/data/cp.target index d2e3871..615125d 100644 --- a/ecnet/datasets/data/cp.target +++ b/src/ecnet/datasets/data/cp.target @@ -40,4 +40,4 @@ 54 65 75 -92 \ No newline at end of file +92 diff --git a/ecnet/datasets/data/kv.smiles b/src/ecnet/datasets/data/kv.smiles similarity index 99% rename from ecnet/datasets/data/kv.smiles rename to src/ecnet/datasets/data/kv.smiles index 3949d04..28f9a96 100644 --- a/ecnet/datasets/data/kv.smiles +++ b/src/ecnet/datasets/data/kv.smiles @@ -210,4 +210,4 @@ CCC(=O)C CCCC(=O)C CCCCC(=O)C CC(C)CC(=O)C -C1=COC(=C1)C=O \ No newline at end of file +C1=COC(=C1)C=O diff --git a/ecnet/datasets/data/kv.target b/src/ecnet/datasets/data/kv.target similarity index 99% rename from ecnet/datasets/data/kv.target rename to src/ecnet/datasets/data/kv.target index 20dcf4f..13ddab3 100644 --- a/ecnet/datasets/data/kv.target +++ b/src/ecnet/datasets/data/kv.target @@ -210,4 +210,4 @@ 0.5184 0.6228 0.6432 -1.1596 \ No newline at end of file +1.1596 diff --git a/ecnet/datasets/data/lhv.smiles b/src/ecnet/datasets/data/lhv.smiles similarity index 99% rename from ecnet/datasets/data/lhv.smiles rename to src/ecnet/datasets/data/lhv.smiles index d0093b4..806b487 100644 --- a/ecnet/datasets/data/lhv.smiles +++ b/src/ecnet/datasets/data/lhv.smiles @@ -385,4 +385,4 @@ CCCCCCCCC(=O)O CCCCCCCCC CC(C)CCC(C)(C)C CCCCCCCCCO -CCCCOCOCCCC \ No newline at end of file +CCCCOCOCCCC diff --git a/ecnet/datasets/data/lhv.target b/src/ecnet/datasets/data/lhv.target similarity index 99% rename from ecnet/datasets/data/lhv.target rename to src/ecnet/datasets/data/lhv.target index 4cb1ba3..2bb479f 100644 --- a/ecnet/datasets/data/lhv.target +++ b/src/ecnet/datasets/data/lhv.target @@ -385,4 +385,4 @@ 45 44 39 -34 \ No newline at end of file +34 diff --git a/ecnet/datasets/data/mon.smiles b/src/ecnet/datasets/data/mon.smiles similarity index 99% rename from ecnet/datasets/data/mon.smiles rename to src/ecnet/datasets/data/mon.smiles index 545a82e..041b019 100644 --- a/ecnet/datasets/data/mon.smiles +++ b/src/ecnet/datasets/data/mon.smiles @@ -305,4 +305,4 @@ CC/C=C/C(C)(C)C CCCCC/C=C/C C/C=C/CCC(C)C CC[C@@H]1CC[C@H](C1)C -CCC(C)(C)CC \ No newline at end of file +CCC(C)(C)CC diff --git a/ecnet/datasets/data/mon.target b/src/ecnet/datasets/data/mon.target similarity index 99% rename from ecnet/datasets/data/mon.target rename to src/ecnet/datasets/data/mon.target index 773aaff..11fab93 100644 --- a/ecnet/datasets/data/mon.target +++ b/src/ecnet/datasets/data/mon.target @@ -305,4 +305,4 @@ 56.5 65.5 59.8 -86.6 \ No newline at end of file +86.6 diff --git a/ecnet/datasets/data/mp.smiles b/src/ecnet/datasets/data/mp.smiles similarity index 99% rename from ecnet/datasets/data/mp.smiles rename to src/ecnet/datasets/data/mp.smiles index da54e69..3fa406f 100644 --- a/ecnet/datasets/data/mp.smiles +++ b/src/ecnet/datasets/data/mp.smiles @@ -144,4 +144,4 @@ C1CC(OC1)CO CCCCCCCCCCCC CCCCC(C)CC CCCCC(CC)CO -C(CO)O \ No newline at end of file +C(CO)O diff --git a/ecnet/datasets/data/mp.target b/src/ecnet/datasets/data/mp.target similarity index 99% rename from ecnet/datasets/data/mp.target rename to src/ecnet/datasets/data/mp.target index 4183b03..9ff1025 100644 --- a/ecnet/datasets/data/mp.target +++ b/src/ecnet/datasets/data/mp.target @@ -144,4 +144,4 @@ -9.6 -121.0 -76.0 --13.0 \ No newline at end of file +-13.0 diff --git a/ecnet/datasets/data/pp.smiles b/src/ecnet/datasets/data/pp.smiles similarity index 99% rename from ecnet/datasets/data/pp.smiles rename to src/ecnet/datasets/data/pp.smiles index f883226..b59f120 100644 --- a/ecnet/datasets/data/pp.smiles +++ b/src/ecnet/datasets/data/pp.smiles @@ -38,4 +38,4 @@ CCCCCCCCCC(=O)OCC(CC)(COC(=O)CCCCCCCCC)COC(=O)CCCCCCCCC CCCCCCCCC(=O)OCC(CC)(COC(=O)CCCCCCCC)COC(=O)CCCCCCCC CCCCCCCC\C=C/CCCCCCCC(=O)OCC(CC)(COC(=O)CCCCCCC\C=C/CCCCCCCC)COC(=O)CCCCCCC\C=C/CCCCCCC CCCCC(CC)C(=O)OCC(CC)(COC(=O)C(CC)CCCC)COC(=O)C(CC)CCCC -CC(C)(C)CC(C)CC(=O)OCC(CC)(COC(=O)CC(C)CC(C)(C)(C))COC(=O)CC(C)CC(C)(C)(C) \ No newline at end of file +CC(C)(C)CC(C)CC(=O)OCC(CC)(COC(=O)CC(C)CC(C)(C)(C))COC(=O)CC(C)CC(C)(C)(C) diff --git a/ecnet/datasets/data/pp.target b/src/ecnet/datasets/data/pp.target similarity index 97% rename from ecnet/datasets/data/pp.target rename to src/ecnet/datasets/data/pp.target index a70dbae..c4dfe0b 100644 --- a/ecnet/datasets/data/pp.target +++ b/src/ecnet/datasets/data/pp.target @@ -38,4 +38,4 @@ -43 -39 -50 --32 \ No newline at end of file +-32 diff --git a/ecnet/datasets/data/ron.smiles b/src/ecnet/datasets/data/ron.smiles similarity index 99% rename from ecnet/datasets/data/ron.smiles rename to src/ecnet/datasets/data/ron.smiles index 545a82e..041b019 100644 --- a/ecnet/datasets/data/ron.smiles +++ b/src/ecnet/datasets/data/ron.smiles @@ -305,4 +305,4 @@ CC/C=C/C(C)(C)C CCCCC/C=C/C C/C=C/CCC(C)C CC[C@@H]1CC[C@H](C1)C -CCC(C)(C)CC \ No newline at end of file +CCC(C)(C)CC diff --git a/ecnet/datasets/data/ron.target b/src/ecnet/datasets/data/ron.target similarity index 99% rename from ecnet/datasets/data/ron.target rename to src/ecnet/datasets/data/ron.target index 3f5f5de..f44dc33 100644 --- a/ecnet/datasets/data/ron.target +++ b/src/ecnet/datasets/data/ron.target @@ -305,4 +305,4 @@ 56.3 71.3 57.6 -80.8 \ No newline at end of file +80.8 diff --git a/ecnet/datasets/data/ysi.smiles b/src/ecnet/datasets/data/ysi.smiles similarity index 99% rename from ecnet/datasets/data/ysi.smiles rename to src/ecnet/datasets/data/ysi.smiles index 5fb78ee..80cf037 100644 --- a/ecnet/datasets/data/ysi.smiles +++ b/src/ecnet/datasets/data/ysi.smiles @@ -555,4 +555,4 @@ CCCCCC(CCCCC)OCC CCCCCCOC(CCC)CCC CCCCCCCCCCC(=O)OC C1=CC=C(C=C1)CC=O -CC(=O)C1=CC=CC=C1 \ No newline at end of file +CC(=O)C1=CC=CC=C1 diff --git a/ecnet/datasets/data/ysi.target b/src/ecnet/datasets/data/ysi.target similarity index 99% rename from ecnet/datasets/data/ysi.target rename to src/ecnet/datasets/data/ysi.target index 15f43f2..cb8cc2b 100644 --- a/ecnet/datasets/data/ysi.target +++ b/src/ecnet/datasets/data/ysi.target @@ -555,4 +555,4 @@ 73.3 58.5 217.6 -130.8 \ No newline at end of file +130.8 diff --git a/src/ecnet/datasets/load_data.py b/src/ecnet/datasets/load_data.py new file mode 100644 index 0000000..b2c290b --- /dev/null +++ b/src/ecnet/datasets/load_data.py @@ -0,0 +1,325 @@ +r"""Pre-bundled data interface""" + +from os import path +from typing import List, Tuple, Union + +from .structs import QSPRDatasetFromFile + +_DATA_PATH = path.join(path.dirname(path.abspath(__file__)), "data") + + +def _open_smiles_file(smiles_fn: str) -> List[str]: + """ + Args: + smiles_fn (str): filename/path for SMILES file + + Returns: + list[str]: [smiles_0, ..., smiles_N] + """ + + with open(smiles_fn, "r") as smi_file: + smiles = smi_file.readlines() + smi_file.close() + smiles = [s.replace("\n", "") for s in smiles] + return smiles + + +def _open_target_file(target_fn: str) -> List[List[float]]: + """ + Args: + target_fn (str): filename/path for target values file + + Returns: + list[list[float]]: lists of target values, in preparation for torch.tensor of shape + (n_targets, 1) + """ + + with open(target_fn, "r") as tar_file: + target = tar_file.readlines() + tar_file.close() + target = [[float(t.replace("\n", ""))] for t in target] + return target + + +def _get_prop_paths(prop: str) -> Tuple[str, str]: + """ + Args: + prop (str): any in ['bp', 'cn', 'cp', 'kv', 'lhv', 'mon', 'pp', 'ron', 'ysi', 'mp'] + + Returns: + tuple[str, str]: (path to smiles file (str), path to targets file (str)) + """ + + return ( + path.join(_DATA_PATH, "{}.smiles".format(prop)), + path.join(_DATA_PATH, "{}.target".format(prop)), + ) + + +def _get_file_data(prop: str) -> Tuple[List[str], List[List[float]]]: + """ + Args: + prop (str): any in ['bp', 'cn', 'cp', 'kv', 'lhv', 'mon', 'pp', 'ron', 'ysi', 'mp'] + + Returns: + tuple[list[str], list[list[float]]]: (smiles, targets) + """ + + fn_smiles, fn_target = _get_prop_paths(prop) + smiles = _open_smiles_file(fn_smiles) + target = _open_target_file(fn_target) + return (smiles, target) + + +def _load_set(prop: str, backend: str) -> QSPRDatasetFromFile: + """ + Args: + prop (str): any in ['bp', 'cn', 'cp', 'kv', 'lhv', 'mon', 'pp', 'ron', 'ysi', 'mp'] + + Returns: + QSPRDatasetFromFile: loaded set + """ + + fn_smiles, fn_target = _get_prop_paths(prop) + target_vals = _open_target_file(fn_target) + return QSPRDatasetFromFile(fn_smiles, target_vals, backend) + + +def load_bp( + as_dataset: bool = False, backend: str = "padel" +) -> Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: + """ + Load boiling point data; target values given in Celsius + + Parameters + ---------- + as_dataset : bool, optional + If True, return a ``QSPRDatasetFromFile``; otherwise return SMILES and targets. + backend : str, optional + Descriptor backend: ``padel`` or ``alvadesc``. + + Returns + ------- + tuple or QSPRDatasetFromFile + SMILES/target pair or dataset object. + """ + + if not as_dataset: + return _get_file_data("bp") + return _load_set("bp", backend) + + +def load_cn( + as_dataset: bool = False, backend: str = "padel" +) -> Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: + """ + Load cetane number data; target values given in CN units + + Parameters + ---------- + as_dataset : bool, optional + If True, return a ``QSPRDatasetFromFile``; otherwise return SMILES and targets. + backend : str, optional + Descriptor backend: ``padel`` or ``alvadesc``. + + Returns + ------- + tuple or QSPRDatasetFromFile + SMILES/target pair or dataset object. + """ + + if not as_dataset: + return _get_file_data("cn") + return _load_set("cn", backend) + + +def load_cp( + as_dataset: bool = False, backend: str = "padel" +) -> Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: + """ + Load cloud point data; target values given in Celsius + + Parameters + ---------- + as_dataset : bool, optional + If True, return a ``QSPRDatasetFromFile``; otherwise return SMILES and targets. + backend : str, optional + Descriptor backend: ``padel`` or ``alvadesc``. + + Returns + ------- + tuple or QSPRDatasetFromFile + SMILES/target pair or dataset object. + """ + + if not as_dataset: + return _get_file_data("cp") + return _load_set("cp", backend) + + +def load_kv( + as_dataset: bool = False, backend: str = "padel" +) -> Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: + """ + Load kinematic viscosity data; target values given in mm^2/s (cSt) at 313 deg. K + + Parameters + ---------- + as_dataset : bool, optional + If True, return a ``QSPRDatasetFromFile``; otherwise return SMILES and targets. + backend : str, optional + Descriptor backend: ``padel`` or ``alvadesc``. + + Returns + ------- + tuple or QSPRDatasetFromFile + SMILES/target pair or dataset object. + """ + + if not as_dataset: + return _get_file_data("kv") + return _load_set("kv", backend) + + +def load_lhv( + as_dataset: bool = False, backend: str = "padel" +) -> Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: + """ + Load lower heating value data; target values given in MJ/kg = kJ/g + + Parameters + ---------- + as_dataset : bool, optional + If True, return a ``QSPRDatasetFromFile``; otherwise return SMILES and targets. + backend : str, optional + Descriptor backend: ``padel`` or ``alvadesc``. + + Returns + ------- + tuple or QSPRDatasetFromFile + SMILES/target pair or dataset object. + """ + + if not as_dataset: + return _get_file_data("lhv") + return _load_set("lhv", backend) + + +def load_mon( + as_dataset: bool = False, backend: str = "padel" +) -> Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: + """ + Load motor octane number data; target values given in MON units + + Parameters + ---------- + as_dataset : bool, optional + If True, return a ``QSPRDatasetFromFile``; otherwise return SMILES and targets. + backend : str, optional + Descriptor backend: ``padel`` or ``alvadesc``. + + Returns + ------- + tuple or QSPRDatasetFromFile + SMILES/target pair or dataset object. + """ + + if not as_dataset: + return _get_file_data("mon") + return _load_set("mon", backend) + + +def load_mp( + as_dataset: bool = False, backend: str = "padel" +) -> Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: + """ + Load melting point data; target values given in Celsius + + Parameters + ---------- + as_dataset : bool, optional + If True, return a ``QSPRDatasetFromFile``; otherwise return SMILES and targets. + backend : str, optional + Descriptor backend: ``padel`` or ``alvadesc``. + + Returns + ------- + tuple or QSPRDatasetFromFile + SMILES/target pair or dataset object. + """ + + if not as_dataset: + return _get_file_data("mp") + return _load_set("mp", backend) + + +def load_pp( + as_dataset: bool = False, backend: str = "padel" +) -> Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: + """ + Load pour point data; target values given in Celsius + + Parameters + ---------- + as_dataset : bool, optional + If True, return a ``QSPRDatasetFromFile``; otherwise return SMILES and targets. + backend : str, optional + Descriptor backend: ``padel`` or ``alvadesc``. + + Returns + ------- + tuple or QSPRDatasetFromFile + SMILES/target pair or dataset object. + """ + + if not as_dataset: + return _get_file_data("pp") + return _load_set("pp", backend) + + +def load_ron( + as_dataset: bool = False, backend: str = "padel" +) -> Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: + """ + Load research octane number data; target values given in RON units + + Parameters + ---------- + as_dataset : bool, optional + If True, return a ``QSPRDatasetFromFile``; otherwise return SMILES and targets. + backend : str, optional + Descriptor backend: ``padel`` or ``alvadesc``. + + Returns + ------- + tuple or QSPRDatasetFromFile + SMILES/target pair or dataset object. + """ + + if not as_dataset: + return _get_file_data("ron") + return _load_set("ron", backend) + + +def load_ysi( + as_dataset: bool = False, backend: str = "padel" +) -> Union[Tuple[List[str], List[List[float]]], QSPRDatasetFromFile]: + """ + Load yield sooting index data; target values given in unified YSI units + + Parameters + ---------- + as_dataset : bool, optional + If True, return a ``QSPRDatasetFromFile``; otherwise return SMILES and targets. + backend : str, optional + Descriptor backend: ``padel`` or ``alvadesc``. + + Returns + ------- + tuple or QSPRDatasetFromFile + SMILES/target pair or dataset object. + """ + + if not as_dataset: + return _get_file_data("ysi") + return _load_set("ysi", backend) diff --git a/ecnet/datasets/structs.py b/src/ecnet/datasets/structs.py similarity index 74% rename from ecnet/datasets/structs.py rename to src/ecnet/datasets/structs.py index ba7fdaf..b42d6ab 100644 --- a/ecnet/datasets/structs.py +++ b/src/ecnet/datasets/structs.py @@ -1,17 +1,21 @@ r"""PyTorch-iterable/callable data structures""" -from typing import List, Tuple, Iterable + +from typing import Iterable, List, Tuple + import torch -from torch.utils.data import Dataset from sklearn.decomposition import PCA +from torch.utils.data import Dataset -from .utils import _qspr_from_padel, _qspr_from_alvadesc,\ - _qspr_from_alvadesc_smifile +from .utils import _qspr_from_alvadesc, _qspr_from_alvadesc_smifile, _qspr_from_padel class QSPRDataset(Dataset): - - def __init__(self, smiles: List[str], target_vals: Iterable[Iterable[float]], - backend: str = 'padel'): + def __init__( + self, + smiles: List[str], + target_vals: Iterable[Iterable[float]], + backend: str = "padel", + ): """ QSPRDataset: creates a torch.utils.data.Dataset from SMILES strings and target values @@ -27,7 +31,9 @@ def __init__(self, smiles: List[str], target_vals: Iterable[Iterable[float]], self.desc_vals = torch.as_tensor(self.desc_vals).type(torch.float32) @staticmethod - def smi_to_qspr(smiles: List[str], backend: str) -> Tuple[List[List[float]], List[str]]: + def smi_to_qspr( + smiles: List[str], backend: str + ) -> Tuple[List[List[float]], List[str]]: """ Generate QSPR descriptors for each supplied SMILES string @@ -39,12 +45,12 @@ def smi_to_qspr(smiles: List[str], backend: str) -> Tuple[List[List[float]], Lis tuple[list[list[float]], list[str]] """ - if backend == 'padel': + if backend == "padel": return _qspr_from_padel(smiles) - elif backend == 'alvadesc': + elif backend == "alvadesc": return _qspr_from_alvadesc(smiles) else: - raise ValueError('Unknown backend software: {}'.format(backend)) + raise ValueError("Unknown backend software: {}".format(backend)) def set_index(self, index: List[int]): """ @@ -54,11 +60,10 @@ def set_index(self, index: List[int]): index (list[int]): indices of the dataset to retain, all others are removed """ + index_t = torch.as_tensor(index, dtype=torch.long) self.smiles = [self.smiles[i] for i in index] - self.target_vals = torch.as_tensor([self.target_vals[i].numpy() for i in index]) - self.desc_vals = torch.as_tensor( - [self.desc_vals[i].numpy() for i in index] - ) + self.target_vals = self.target_vals[index_t] + self.desc_vals = self.desc_vals[index_t] def set_desc_index(self, index: List[int]): """ @@ -68,9 +73,8 @@ def set_desc_index(self, index: List[int]): index (list[int]): indices of the features to retain, all others are removed """ - self.desc_vals = torch.as_tensor( - [[val[i] for i in index] for val in self.desc_vals] - ) + index_t = torch.as_tensor(index, dtype=torch.long) + self.desc_vals = self.desc_vals[:, index_t] self.desc_names = [self.desc_names[i] for i in index] def __len__(self): @@ -89,17 +93,20 @@ def __getitem__(self, idx: int): target_val = self.target_vals[idx] dv = self.desc_vals[idx] return { - 'smiles': smiles, - 'target_val': target_val, - 'desc_vals': dv, - 'desc_names': self.desc_names + "smiles": smiles, + "target_val": target_val, + "desc_vals": dv, + "desc_names": self.desc_names, } class QSPRDatasetFromFile(QSPRDataset): - - def __init__(self, smiles_fn: str, target_vals: Iterable[Iterable[float]], - backend: str = 'padel'): + def __init__( + self, + smiles_fn: str, + target_vals: Iterable[Iterable[float]], + backend: str = "padel", + ): """ QSPRDatasetFromFile: creates a torch.utils.data.Dataset given target values and a supplied filename/path to a SMILES file @@ -112,15 +119,11 @@ def __init__(self, smiles_fn: str, target_vals: Iterable[Iterable[float]], self.smiles = self._open_smiles_file(smiles_fn) self.target_vals = torch.as_tensor(target_vals).type(torch.float32) - if backend == 'padel': - self.desc_vals, self.desc_names = self.smi_to_qspr( - self.smiles, backend - ) + if backend == "padel": + self.desc_vals, self.desc_names = self.smi_to_qspr(self.smiles, backend) self.desc_vals = torch.as_tensor(self.desc_vals).type(torch.float32) - elif backend == 'alvadesc': - self.desc_vals, self.desc_names = _qspr_from_alvadesc_smifile( - smiles_fn - ) + elif backend == "alvadesc": + self.desc_vals, self.desc_names = _qspr_from_alvadesc_smifile(smiles_fn) self.desc_vals = torch.as_tensor(self.desc_vals).type(torch.float32) @staticmethod @@ -135,17 +138,19 @@ def _open_smiles_file(smiles_fn: str) -> List[str]: list[str]: SMILES strings """ - with open(smiles_fn, 'r') as smi_file: + with open(smiles_fn, "r") as smi_file: smiles = smi_file.readlines() smi_file.close() - smiles = [s.replace('\n', '') for s in smiles] + smiles = [s.replace("\n", "") for s in smiles] return smiles class QSPRDatasetFromValues(QSPRDataset): - - def __init__(self, desc_vals: Iterable[Iterable[float]], - target_vals: Iterable[Iterable[float]]): + def __init__( + self, + desc_vals: Iterable[Iterable[float]], + target_vals: Iterable[Iterable[float]], + ): """ QSPRDatasetFromValues: creates a torch.utils.data.Dataset given supplied descriptor values, supplied target values @@ -155,16 +160,20 @@ def __init__(self, desc_vals: Iterable[Iterable[float]], target_vals (Iterable[Iterable[float]]): target values, shape (n_samples, n_targets) """ - self.smiles = ['' for _ in range(len(target_vals))] - self.desc_names = ['' for _ in range(len(desc_vals[0]))] + self.smiles = ["" for _ in range(len(target_vals))] + self.desc_names = ["" for _ in range(len(desc_vals[0]))] self.desc_vals = torch.as_tensor(desc_vals).type(torch.float32) self.target_vals = torch.as_tensor(target_vals).type(torch.float32) class PCADataset(QSPRDataset): - - def __init__(self, smiles: List[str], target_vals: Iterable[Iterable[float]], - backend: str = 'padel', existing_pca_dataset: 'PCADataset' = None): + def __init__( + self, + smiles: List[str], + target_vals: Iterable[Iterable[float]], + backend: str = "padel", + existing_pca_dataset: "PCADataset" = None, + ): """ PCADataset: creates a torch.utils.data.Dataset given supplied SMILES strings, supplied target values; first generates QSPR descriptors, then transforms them via PCA; an existing @@ -188,4 +197,6 @@ def __init__(self, smiles: List[str], target_vals: Iterable[Iterable[float]], self.pca.fit(desc_vals) else: self.pca = existing_pca_dataset.pca - self.desc_vals = torch.as_tensor(self.pca.transform(desc_vals)).type(torch.float32) + self.desc_vals = torch.as_tensor(self.pca.transform(desc_vals)).type( + torch.float32 + ) diff --git a/ecnet/datasets/utils.py b/src/ecnet/datasets/utils.py similarity index 57% rename from ecnet/datasets/utils.py rename to src/ecnet/datasets/utils.py index 752bf3a..c2eea22 100644 --- a/ecnet/datasets/utils.py +++ b/src/ecnet/datasets/utils.py @@ -1,10 +1,35 @@ r"""Utility functions for generating QSPR descriptors""" + from typing import List, Tuple -from alvadescpy import alvadesc, smiles_to_descriptors -from padelpy import from_smiles -def _qspr_from_padel(smiles: List[str], timeout: int = None) -> Tuple[List[List[float]], List[str]]: +def _import_padelpy(): + """Import padelpy on demand (default backend).""" + from padelpy import from_smiles + + return from_smiles + + +def _import_alvadescpy(): + """Import alvadescpy on demand (optional licensed backend).""" + try: + from alvadescpy import alvadesc, smiles_to_descriptors + except ModuleNotFoundError as exc: + # setuptools >=82 may omit pkg_resources; alvadescpy still imports it. + if exc.name in {"pkg_resources", "alvadescpy"}: + raise ModuleNotFoundError( + "alvaDesc backend requires a working alvadescpy install. " + "If the error mentions pkg_resources, install an older " + "setuptools (for example `pip install 'setuptools<82'`) or " + "use backend='padel'." + ) from exc + raise + return alvadesc, smiles_to_descriptors + + +def _qspr_from_padel( + smiles: List[str], timeout: int = None +) -> Tuple[List[List[float]], List[str]]: """ Args: smiles (list[str]): list of SMILES strings @@ -16,13 +41,14 @@ def _qspr_from_padel(smiles: List[str], timeout: int = None) -> Tuple[List[List[ descriptor names) """ + from_smiles = _import_padelpy() if timeout is None: timeout = len(smiles) desc = from_smiles(smiles, timeout=max(15, len(smiles))) keys = list(desc[0].keys()) for idx, d in enumerate(desc): for k in keys: - if d[k] == '': + if d[k] == "": desc[idx][k] = 0.0 desc = [[float(d[k]) for k in keys] for d in desc] return (desc, keys) @@ -38,11 +64,12 @@ def _qspr_from_alvadesc(smiles: List[str]) -> Tuple[List[List[float]], List[str] descriptor names) """ + _, smiles_to_descriptors = _import_alvadescpy() desc = smiles_to_descriptors(smiles) keys = list(desc[0].keys()) for idx, d in enumerate(desc): for k in keys: - if d[k] == 'na' or d[k] == r'na\r': + if d[k] == "na" or d[k] == r"na\r": desc[idx][k] = 0.0 desc = [[float(d[k]) for k in keys] for d in desc] return (desc, keys) @@ -58,15 +85,17 @@ def _qspr_from_alvadesc_smifile(smiles_fn: str) -> Tuple[List[List[float]], List descriptor names) """ - desc = alvadesc(input_file=smiles_fn, inputtype='SMILES', - descriptors='ALL', labels=True) + alvadesc, _ = _import_alvadescpy() + desc = alvadesc( + input_file=smiles_fn, inputtype="SMILES", descriptors="ALL", labels=True + ) for d in desc: - d.pop('No.') - d.pop('NAME') + d.pop("No.") + d.pop("NAME") keys = list(desc[0].keys()) for idx, d in enumerate(desc): for k in keys: - if d[k] == 'na' or d[k] == r'na\r': + if d[k] == "na" or d[k] == r"na\r": desc[idx][k] = 0.0 desc = [[float(d[k]) for k in keys] for d in desc] return (desc, keys) diff --git a/ecnet/model.py b/src/ecnet/model.py similarity index 50% rename from ecnet/model.py rename to src/ecnet/model.py index a766419..186173d 100644 --- a/ecnet/model.py +++ b/src/ecnet/model.py @@ -4,25 +4,32 @@ Developed in 2021 by """ -from typing import List, Tuple, Union -import numpy as np +from re import compile +from typing import List, Tuple + import torch import torch.nn as nn import torch.nn.functional as F -from torch.utils.data import Subset, DataLoader from sklearn.model_selection import train_test_split -from re import compile +from torch.utils.data import DataLoader, Subset -from .datasets.structs import QSPRDataset from .callbacks import CallbackOperator, LRDecayLinear, Validator +from .datasets.structs import QSPRDataset -_TORCH_MODEL_FN = compile(r'.*\.pt') +_TORCH_MODEL_FN = compile(r".*\.pt") +_STATE_FORMAT = "ecnet-state-v1" class ECNet(nn.Module): - - def __init__(self, input_dim: int, output_dim: int, hidden_dim: int, n_hidden: int, - dropout: float = 0.0, device: str = 'cpu'): + def __init__( + self, + input_dim: int, + output_dim: int, + hidden_dim: int, + n_hidden: int, + dropout: float = 0.0, + device: str = "cpu", + ): """ ECNet, child of torch.nn.Module: handles data preprocessing, multilayer perceptron training, stores multilayer perceptron layers/weights for continued usage/saving @@ -57,47 +64,67 @@ def _construct(self): self.model.append(nn.Linear(self._hidden_dim, self._hidden_dim)) self.model.append(nn.Linear(self._hidden_dim, self._output_dim)) - def fit(self, smiles: List[str] = None, target_vals: List[List[float]] = None, - dataset: QSPRDataset = None, backend: str = 'padel', batch_size: int = 32, - epochs: int = 100, lr_decay: float = 0.0, valid_size: float = 0.0, - valid_eval_iter: int = 1, patience: int = 16, verbose: int = 0, - random_state: int = None, shuffle: bool = False, - **kwargs) -> Tuple[List[float], List[float]]: + def fit( + self, + smiles: List[str] = None, + target_vals: List[List[float]] = None, + dataset: QSPRDataset = None, + backend: str = "padel", + batch_size: int = 32, + epochs: int = 100, + lr_decay: float = 0.0, + valid_size: float = 0.0, + valid_eval_iter: int = 1, + patience: int = 16, + verbose: int = 0, + random_state: int = None, + shuffle: bool = False, + **kwargs, + ) -> Tuple[List[float], List[float]]: """ - fit: fits ECNet to either (1) SMILES and target values, or (2) a pre-loaded QSPRDataset; - the training process utilizes the Adam optimization algorithm, MSE loss, ReLU activation - functions between fully-connected layers, and optionally (1) a decaying learning rate, and - (2) periodic validation during regression; periodic validation is used to determine when - training ends (i.e. when a new minimum validation loss is not achieved after N epochs) - - Args: - smiles (list[str], optional): if `dataset` not supplied, generates QSPR descriptors - using these SMILES strings for use as input data - target_vals (list[list[float]], optional): if `dataset` not supplied, this data is - used for regression; should be shape (n_samples, n_targets) - dataset (QSPRDataset, optional): pre-loaded dataset with descriptors + target values - backend (str, optional): if using SMILES strings and target values, specifies backend - software to use for QSPR generation; either 'padel' or 'alvadesc', default 'padel' - batch_size (int, optional): training batch size; default = 32 - epochs (int, optional): number of training epochs; default = 100 - lr_decay (float, optional): linear rate of decay for learning rate; default = 0.0 - valid_size (float, optional): supply >0.0 to utilize periodic validation; value - specifies proportion of supplied data to be used for validation - valid_eval_iter (int, optional): validation set is evaluated every `this` epochs; - default = 1 (evaluated every epoch) - patience (int, optional): if new lowest validation loss not found after `this` many - epochs, terminate training, set model parameters to those observed @ lowest - validation loss - verbose (int, optional): if > 0, will print every `this` epochs; default = 0 - random_state (int, optional): random_state used by sklearn.model_selection. - train_test_split; default = None - shuffle (bool, optional): if True, shuffles training/validation data between epochs; - default = False; random_state should be None - **kwargs: arguments accepted by torch.optim.Adam (i.e. learning rate, beta values) - - Returns: - Tuple[List[float], List[Union[float, None]]]: (training losses, validation losses); if - valid_size == 0.0, (training losses, [0, ..., 0]) + Fit ECNet to SMILES/target values or a pre-loaded QSPRDataset. + + Training uses Adam, MSE loss, and ReLU activations between layers. + Optional linear learning-rate decay and validation-based early stopping + are supported when ``valid_size > 0``. + + Parameters + ---------- + smiles : list[str], optional + SMILES strings used to build descriptors when ``dataset`` is omitted. + target_vals : list[list[float]], optional + Regression targets when ``dataset`` is omitted. + dataset : QSPRDataset, optional + Pre-loaded dataset with descriptors and targets. + backend : str, optional + Descriptor backend when building from SMILES (``padel`` or + ``alvadesc``). Default ``padel``. + batch_size : int, optional + Training batch size. Default 32. + epochs : int, optional + Number of training epochs. Default 100. + lr_decay : float, optional + Linear learning-rate decay per epoch. Default 0.0. + valid_size : float, optional + Fraction of data held out for validation. Default 0.0. + valid_eval_iter : int, optional + Validate every this many epochs. Default 1. + patience : int, optional + Early-stopping patience in epochs. Default 16. + verbose : int, optional + Print progress every this many epochs when > 0. Default 0. + random_state : int, optional + Seed for train/validation split. Default None. + shuffle : bool, optional + Shuffle data between epochs. Default False. + **kwargs + Forwarded to ``torch.optim.Adam``. + + Returns + ------- + tuple[list[float], list[float]] + Training losses and validation losses (zeros when + ``valid_size == 0``). """ # Data preparation @@ -105,8 +132,9 @@ def fit(self, smiles: List[str] = None, target_vals: List[List[float]] = None, dataset = QSPRDataset(smiles, target_vals, backend) if valid_size > 0.0: index_train, index_valid = train_test_split( - [i for i in range(len(dataset))], test_size=valid_size, - random_state=random_state + [i for i in range(len(dataset))], + test_size=valid_size, + random_state=random_state, ) dataloader_train = DataLoader( Subset(dataset, index_train), batch_size=batch_size, shuffle=True @@ -122,8 +150,8 @@ def fit(self, smiles: List[str] = None, target_vals: List[List[float]] = None, # Set up callbacks CBO = CallbackOperator() - if 'lr' in kwargs: - _lr = kwargs.get('lr') + if "lr" in kwargs: + _lr = kwargs.get("lr") _lrdecay = LRDecayLinear(_lr, lr_decay, optimizer) CBO.add_cb(_lrdecay) if valid_size > 0.0: @@ -134,35 +162,36 @@ def fit(self, smiles: List[str] = None, target_vals: List[List[float]] = None, # TRAIN BEGIN CBO.on_train_begin() for epoch in range(epochs): - # EPOCH BEGIN if not CBO.on_epoch_begin(epoch): break if shuffle: index_train, index_valid = train_test_split( - [i for i in range(len(dataset))], test_size=valid_size, - random_state=random_state + [i for i in range(len(dataset))], + test_size=valid_size, + random_state=random_state, ) dataloader_train = DataLoader( Subset(dataset, index_train), batch_size=batch_size, shuffle=True ) dataloader_valid = DataLoader( - Subset(dataset, index_valid), batch_size=len(index_valid), shuffle=True + Subset(dataset, index_valid), + batch_size=len(index_valid), + shuffle=True, ) train_loss = 0.0 self.train() for b_idx, batch in enumerate(dataloader_train): - # BATCH BEGIN if not CBO.on_batch_begin(b_idx): break optimizer.zero_grad() - pred = self(batch['desc_vals']) - target = batch['target_val'] + pred = self(batch["desc_vals"]) + target = batch["target_val"] # BATCH END, LOSS BEGIN if not CBO.on_batch_end(b_idx): @@ -180,30 +209,33 @@ def fit(self, smiles: List[str] = None, target_vals: List[List[float]] = None, break optimizer.step() - train_loss += loss.detach().item() * len(batch['target_val']) + train_loss += loss.detach().item() * len(batch["target_val"]) # STEP END if not CBO.on_step_end(b_idx): break - # Determine epoch loss for training, validation data + # Determine epoch loss for training, validation data. + # Run epoch-end callbacks first so Validator evaluates this epoch + # before we record/print ``valid_loss`` (avoids the unset + # ``sys.maxsize`` sentinel and a one-epoch lag). train_loss /= len(dataloader_train.dataset) + continue_training = CBO.on_epoch_end(epoch) if valid_size > 0.0: - valid_loss = _validator._most_recent_loss + valid_loss = float(_validator._most_recent_loss) else: valid_loss = 0.0 train_losses.append(train_loss) valid_losses.append(valid_loss) - # Print losses if verbose - if verbose: - if epoch % verbose == 0: - print('Epoch: {} | Train loss: {} | Valid loss: {}'.format( + if verbose and epoch % verbose == 0: + print( + "Epoch: {} | Train loss: {} | Valid loss: {}".format( epoch, train_loss, valid_loss - )) + ) + ) - # EPOCH END - if not CBO.on_epoch_end(epoch): + if not continue_training: break # TRAIN END @@ -228,15 +260,20 @@ def forward(self, x: torch.tensor) -> torch.tensor: return self.model[-1](x) def loss(self, pred: torch.tensor, target: torch.tensor) -> torch.tensor: - r""" - Computes mean squared error between predicted values, target values - - Args: - pred (torch.tensor): predicted values, shape (n_samples, n_features) - target (torch.tensor): real values, shape (n_samples, n_features) - - Returns: - torch.tensor: MSE loss, shape (*, 1) + """ + Compute mean squared error between predicted and target values. + + Parameters + ---------- + pred : torch.Tensor + Predicted values, shape ``(n_samples, n_features)``. + target : torch.Tensor + Target values, shape ``(n_samples, n_features)``. + + Returns + ------- + torch.Tensor + MSE loss. """ return F.mse_loss(pred, target) @@ -250,8 +287,19 @@ def save(self, model_filename: str): """ if _TORCH_MODEL_FN.match(model_filename) is None: - raise ValueError('Models must be saved with a `.pt` extension') - torch.save(self, model_filename) + raise ValueError("Models must be saved with a `.pt` extension") + payload = { + "format": _STATE_FORMAT, + "arch": { + "input_dim": self._input_dim, + "output_dim": self._output_dim, + "hidden_dim": self._hidden_dim, + "n_hidden": self._n_hidden, + "dropout": self._dropout, + }, + "state_dict": self.state_dict(), + } + torch.save(payload, model_filename) def load_model(model_filename: str) -> ECNet: @@ -260,8 +308,30 @@ def load_model(model_filename: str) -> ECNet: Args: model_filename (str): filename/path to load model from + + Notes: + Accepts legacy full-module ``.pt`` pickles and the preferred + ``ecnet-state-v1`` state-dict payload written by :meth:`ECNet.save`. """ - model = torch.load(model_filename) - model.eval() - return model + # weights_only=False: required for legacy full-module pickles (Q8 shim). + payload = torch.load(model_filename, map_location="cpu", weights_only=False) + if isinstance(payload, ECNet): + payload.eval() + return payload + if isinstance(payload, dict) and payload.get("format") == _STATE_FORMAT: + arch = payload["arch"] + model = ECNet( + arch["input_dim"], + arch["output_dim"], + arch["hidden_dim"], + arch["n_hidden"], + dropout=arch.get("dropout", 0.0), + ) + model.load_state_dict(payload["state_dict"]) + model.eval() + return model + raise ValueError( + "Unrecognized ECNet checkpoint; expected a legacy ECNet pickle or " + f"an {_STATE_FORMAT!r} state-dict payload" + ) diff --git a/src/ecnet/tasks/__init__.py b/src/ecnet/tasks/__init__.py new file mode 100644 index 0000000..bbc4bb4 --- /dev/null +++ b/src/ecnet/tasks/__init__.py @@ -0,0 +1,17 @@ +from .feature_selection import select_rfr +from .parameter_tuning import ( + CONFIG, + N_TESTS, + tune_batch_size, + tune_model_architecture, + tune_training_parameters, +) + +__all__ = [ + "CONFIG", + "N_TESTS", + "select_rfr", + "tune_batch_size", + "tune_model_architecture", + "tune_training_parameters", +] diff --git a/src/ecnet/tasks/feature_selection.py b/src/ecnet/tasks/feature_selection.py new file mode 100644 index 0000000..bcaefa3 --- /dev/null +++ b/src/ecnet/tasks/feature_selection.py @@ -0,0 +1,48 @@ +r"""Feature selection functions""" + +from typing import List, Tuple + +from sklearn.ensemble import RandomForestRegressor + +from ..datasets.structs import QSPRDataset + + +def select_rfr( + dataset: QSPRDataset, total_importance: float = 0.95, **kwargs +) -> Tuple[List[int], List[float]]: + """ + Reduce descriptor dimensionality by random-forest feature importance. + + Parameters + ---------- + dataset : QSPRDataset + Input dataset. + total_importance : float + Cumulative importance fraction to retain. + **kwargs + Forwarded to ``sklearn.ensemble.RandomForestRegressor``. + + Returns + ------- + tuple[list[int], list[float]] + Selected feature indices and their importances. + """ + + X = dataset.desc_vals + y = [dv[0] for dv in dataset.target_vals] + regr = RandomForestRegressor(**kwargs) + regr.fit(X, y) + importances = sorted( + [(regr.feature_importances_[i], i) for i in range(len(dataset.desc_vals[0]))], + key=lambda x: x[0], + reverse=True, + ) + tot_imp = 0.0 + for idx, i in enumerate(importances): + tot_imp += i[0] + idx_cutoff = idx + if tot_imp >= total_importance: + break + desc_imp = [i[0] for i in importances][:idx_cutoff] + desc_idx = [i[1] for i in importances][:idx_cutoff] + return (desc_idx, desc_imp) diff --git a/src/ecnet/tasks/parameter_tuning.py b/src/ecnet/tasks/parameter_tuning.py new file mode 100644 index 0000000..6fcd54a --- /dev/null +++ b/src/ecnet/tasks/parameter_tuning.py @@ -0,0 +1,304 @@ +from typing import Iterable + +import numpy as np +from ecabc import ABC +from sklearn.metrics import median_absolute_error + +from ..datasets.structs import QSPRDataset +from ..model import ECNet + +N_TESTS = 10 + +CONFIG = { + "training_params_range": {"lr": (1e-16, 0.05), "lr_decay": (1e-16, 0.0001)}, + "architecture_params_range": { + "hidden_dim": (1, 1024), + "n_hidden": (1, 5), + "dropout": (0.0, 0.1), + }, +} + + +def _get_kwargs(**kwargs): + """ + Returns dictionary of relevant training parameters from **kwargs + + Args: + **kwargs: key word arguments + + Returns: + dict: relevant relevant kwargs, else default values + """ + + return { + "model": kwargs.get("model"), + "train_ds": kwargs.get("train_ds"), + "eval_ds": kwargs.get("eval_ds"), + "epochs": kwargs.get("epochs", 100), + "batch_size": kwargs.get("batch_size", 32), + "valid_size": kwargs.get("valid_size", 0.2), + "patience": kwargs.get("patience", 32), + "lr_decay": kwargs.get("lr_decay", 0.0), + "lr": kwargs.get("lr", 0.001), + "beta_1": kwargs.get("beta_1", 0.9), + "beta_2": kwargs.get("beta_2", 0.999), + "eps": kwargs.get("eps", 1e-08), + "weight_decay": kwargs.get("weight_decay", 0.0), + "hidden_dim": kwargs.get("hidden_dim", 128), + "n_hidden": kwargs.get("n_hidden", 2), + "dropout": kwargs.get("dropout", 0.0), + "amsgrad": kwargs.get("amsgrad", False), + } + + +def _evaluate_model(trial_spec: dict) -> float: + """ + Training sub-function for cost functions _cost_batch_size, _cost_arch, _cost_train_hp; + Each model configuration is tested ecnet.tasks.parameter_tuning.N_TESTS times, average + median absolute error across all tests returned; default 10 tests per configuration + + Args: + trial_spec (dict): all relevant parameters for this training trial + + Returns: + float: median absolute error for dataset being evaluated (trial_spec['eval_ds']) + """ + + model = ECNet( + trial_spec["train_ds"].desc_vals.shape[1], + trial_spec["train_ds"].target_vals.shape[1], + trial_spec["hidden_dim"], + trial_spec["n_hidden"], + trial_spec["dropout"], + ) + maes = [] + for _ in range(N_TESTS): + model._construct() + model.fit( + dataset=trial_spec["train_ds"], + epochs=trial_spec["epochs"], + batch_size=trial_spec["batch_size"], + patience=trial_spec["patience"], + lr_decay=trial_spec["lr_decay"], + lr=trial_spec["lr_decay"], + betas=(trial_spec["beta_1"], trial_spec["beta_2"]), + eps=trial_spec["eps"], + weight_decay=trial_spec["weight_decay"], + amsgrad=trial_spec["amsgrad"], + ) + yhat_eval = model(trial_spec["eval_ds"].desc_vals).detach().numpy() + y_eval = trial_spec["eval_ds"].target_vals + maes.append(median_absolute_error(y_eval, yhat_eval)) + return np.mean(maes) + + +def _cost_batch_size(vals: Iterable[float], **kwargs) -> float: + """ + Cost function for tuning batch size + + Args: + vals (iterable[float]): values passed to cost function from ABC; just contains batch size + **kwargs: user-defined training arguments, datasets to be passed to _evaluate_model + + Returns: + float: median absolute error for dataset being evaluated (**kwarg: eval_ds) + """ + + trial_spec = _get_kwargs(**kwargs) + trial_spec["batch_size"] = vals[0] + return _evaluate_model(trial_spec) + + +def tune_batch_size( + n_bees: int, + n_iter: int, + dataset_train: QSPRDataset, + dataset_eval: QSPRDataset, + n_processes: int = 1, + **kwargs, +) -> dict: + """ + Tune training batch size with an artificial bee colony search. + + Parameters + ---------- + n_bees : int + Number of employer bees in the ABC algorithm. + n_iter : int + Number of ABC search iterations. + dataset_train : QSPRDataset + Training dataset. + dataset_eval : QSPRDataset + Evaluation dataset. + n_processes : int, optional + Process count for parallel evaluation. Default 1. + **kwargs + Training hyperparameters forwarded to model evaluation. + + Returns + ------- + dict + Mapping with key ``batch_size``. + """ + + kwargs["train_ds"] = dataset_train + kwargs["eval_ds"] = dataset_eval + abc = ABC(n_bees, _cost_batch_size, num_processes=n_processes, obj_fn_args=kwargs) + abc.add_param(1, len(kwargs.get("train_ds").desc_vals), name="batch_size") + abc.initialize() + for _ in range(n_iter): + abc.search() + return {"batch_size": abc.best_params["batch_size"]} + + +def _cost_arch(vals, **kwargs): + """ + Cost function for tuning NN architecture + + Args: + vals (iterable[float]): values passed to cost function from ABC; contains: + - hidden_dim + - n_nidden + - dropout + **kwargs: user-defined training arguments, datasets to be passed to _evaluate_model + + Returns: + float: median absolute error for dataset being evaluated (**kwarg: eval_ds) + """ + + trial_spec = _get_kwargs(**kwargs) + trial_spec["hidden_dim"] = vals[0] + trial_spec["n_hidden"] = vals[1] + trial_spec["dropout"] = vals[2] + return _evaluate_model(trial_spec) + + +def tune_model_architecture( + n_bees: int, + n_iter: int, + dataset_train: QSPRDataset, + dataset_eval: QSPRDataset, + n_processes: int = 1, + **kwargs, +) -> dict: + """ + Tune hidden-layer width, depth, and dropout with ABC search. + + Parameters + ---------- + n_bees : int + Number of employer bees in the ABC algorithm. + n_iter : int + Number of ABC search iterations. + dataset_train : QSPRDataset + Training dataset. + dataset_eval : QSPRDataset + Evaluation dataset. + n_processes : int, optional + Process count for parallel evaluation. Default 1. + **kwargs + Training hyperparameters forwarded to model evaluation. + + Returns + ------- + dict + Mapping with keys ``hidden_dim``, ``n_hidden``, and ``dropout``. + """ + + kwargs["train_ds"] = dataset_train + kwargs["eval_ds"] = dataset_eval + abc = ABC(n_bees, _cost_arch, num_processes=n_processes, obj_fn_args=kwargs) + abc.add_param( + CONFIG["architecture_params_range"]["hidden_dim"][0], + CONFIG["architecture_params_range"]["hidden_dim"][1], + name="hidden_dim", + ) + abc.add_param( + CONFIG["architecture_params_range"]["n_hidden"][0], + CONFIG["architecture_params_range"]["n_hidden"][1], + name="n_hidden", + ) + abc.add_param( + CONFIG["architecture_params_range"]["dropout"][0], + CONFIG["architecture_params_range"]["dropout"][1], + name="dropout", + ) + abc.initialize() + for _ in range(n_iter): + abc.search() + return { + "hidden_dim": abc.best_params["hidden_dim"], + "n_hidden": abc.best_params["n_hidden"], + "dropout": abc.best_params["dropout"], + } + + +def _cost_train_hp(vals, **kwargs): + """ + Cost function for tuning NN training parameters (Adam optim. hyper-parameters) + + Args: + vals (iterable[float]): values passed to cost function from ABC; contains: + - lr (learning rate) + - lr_decay (learning rate decay) + **kwargs: user-defined training arguments, datasets to be passed to _evaluate_model + + Returns: + float: median absolute error for dataset being evaluated (**kwarg: eval_ds) + """ + + trial_spec = _get_kwargs(**kwargs) + trial_spec["lr"] = vals[0] + trial_spec["lr_decay"] = vals[1] + return _evaluate_model(trial_spec) + + +def tune_training_parameters( + n_bees: int, + n_iter: int, + dataset_train: QSPRDataset, + dataset_eval: QSPRDataset, + n_processes: int = 1, + **kwargs, +) -> dict: + """ + Tune learning rate and learning-rate decay with ABC search. + + Parameters + ---------- + n_bees : int + Number of employer bees in the ABC algorithm. + n_iter : int + Number of ABC search iterations. + dataset_train : QSPRDataset + Training dataset. + dataset_eval : QSPRDataset + Evaluation dataset. + n_processes : int, optional + Process count for parallel evaluation. Default 1. + **kwargs + Training hyperparameters forwarded to model evaluation. + + Returns + ------- + dict + Mapping with keys ``lr`` and ``lr_decay``. + """ + + kwargs["train_ds"] = dataset_train + kwargs["eval_ds"] = dataset_eval + abc = ABC(n_bees, _cost_train_hp, num_processes=n_processes, obj_fn_args=kwargs) + abc.add_param( + CONFIG["training_params_range"]["lr"][0], + CONFIG["training_params_range"]["lr"][1], + name="lr", + ) + abc.add_param( + CONFIG["training_params_range"]["lr_decay"][0], + CONFIG["training_params_range"]["lr_decay"][1], + name="lr_decay", + ) + abc.initialize() + for _ in range(n_iter): + abc.search() + return {"lr": abc.best_params["lr"], "lr_decay": abc.best_params["lr_decay"]} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/blends/test_blend_errors.py b/tests/blends/test_blend_errors.py new file mode 100644 index 0000000..b7ae4a1 --- /dev/null +++ b/tests/blends/test_blend_errors.py @@ -0,0 +1,56 @@ +"""Golden tests for blend error-propagation helpers. + +Formulas follow ``ecnet.blends.equations`` docstrings. Fixtures are +self-consistency oracles (hand-computed); see ``tests/fixtures/blend_errors.py``. +""" + +from __future__ import annotations + +import pytest +from tests.fixtures.blend_errors import ( + EXPONENTIAL_ERR_CASES, + KV_ERR_CASES, + LINEAR_ERR_CASES, + ExponentialErrCase, + KvErrCase, + LinearErrCase, +) + +from ecnet.blends import exponential_blend_err, kv_error, linear_blend_err + + +@pytest.mark.parametrize( + "case", + LINEAR_ERR_CASES, + ids=[c.case_id for c in LINEAR_ERR_CASES], +) +def test_linear_blend_err_oracle(case: LinearErrCase) -> None: + result = linear_blend_err(case.errors, case.proportions) + assert result == pytest.approx(case.expected, abs=case.abs_tol, rel=0.0) + + +@pytest.mark.parametrize( + "case", + EXPONENTIAL_ERR_CASES, + ids=[c.case_id for c in EXPONENTIAL_ERR_CASES], +) +def test_exponential_blend_err_oracle(case: ExponentialErrCase) -> None: + result = exponential_blend_err( + case.values, + case.result, + case.errors, + case.proportions, + case.a, + case.b, + ) + assert result == pytest.approx(case.expected, abs=case.abs_tol, rel=0.0) + + +@pytest.mark.parametrize( + "case", + KV_ERR_CASES, + ids=[c.case_id for c in KV_ERR_CASES], +) +def test_kv_error_oracle(case: KvErrCase) -> None: + result = kv_error(case.values, case.errors, case.proportions) + assert result == pytest.approx(case.expected, abs=case.abs_tol, rel=0.0) diff --git a/tests/blends/test_linear_blends.py b/tests/blends/test_linear_blends.py new file mode 100644 index 0000000..feeec83 --- /dev/null +++ b/tests/blends/test_linear_blends.py @@ -0,0 +1,35 @@ +"""Golden tests for linear blend property predictors (CN, YSI, LHV). + +Sources cited in ``ecnet.blends.predict`` (linear volume-fraction mixing): + +- Cetane number: NREL/SR-540-36805 +- Yield sooting index: https://doi.org/10.1016/j.fuel.2020.119522 +- Lower heating value: https://doi.org/10.1016/j.ejpe.2015.11.002 + +Fixtures are self-consistency oracles (hand-computed ``sum(V_i * x_i)``), +not literature table lookups. See ``tests/fixtures/linear_blends.py``. +""" + +from __future__ import annotations + +import pytest +from tests.fixtures.linear_blends import LINEAR_BLEND_CASES, LinearBlendCase + +from ecnet.blends import cetane_number, lower_heating_value, yield_sooting_index + +_PREDICTORS = [ + pytest.param(cetane_number, id="cetane_number"), + pytest.param(yield_sooting_index, id="yield_sooting_index"), + pytest.param(lower_heating_value, id="lower_heating_value"), +] + + +@pytest.mark.parametrize("predictor", _PREDICTORS) +@pytest.mark.parametrize( + "case", + LINEAR_BLEND_CASES, + ids=[c.case_id for c in LINEAR_BLEND_CASES], +) +def test_linear_blend_oracle(predictor, case: LinearBlendCase) -> None: + result = predictor(case.values, case.vol_fractions) + assert result == pytest.approx(case.expected, abs=case.abs_tol, rel=0.0) diff --git a/tests/blends/test_nonlinear_blends.py b/tests/blends/test_nonlinear_blends.py new file mode 100644 index 0000000..d2c00cf --- /dev/null +++ b/tests/blends/test_nonlinear_blends.py @@ -0,0 +1,42 @@ +"""Golden tests for nonlinear blend predictors (cloud point, kinematic viscosity). + +Sources cited in ``ecnet.blends.predict``: + +- Cloud point (°C in / °C out; Rankine internally): Semwal et al., diesel + blending cold-flow model with exponent 13.45 +- Kinematic viscosity (cSt): Ding et al., equation 8 mixing rule + +Fixtures are self-consistency oracles (independent stdlib reimplementation), +not literature table lookups. See ``tests/fixtures/nonlinear_blends.py``. +""" + +from __future__ import annotations + +import pytest +from tests.fixtures.nonlinear_blends import ( + CLOUD_POINT_CASES, + KINEMATIC_VISCOSITY_CASES, + NonlinearBlendCase, +) + +from ecnet.blends import cloud_point, kinematic_viscosity + + +@pytest.mark.parametrize( + "case", + CLOUD_POINT_CASES, + ids=[c.case_id for c in CLOUD_POINT_CASES], +) +def test_cloud_point_oracle(case: NonlinearBlendCase) -> None: + result = cloud_point(case.values, case.vol_fractions) + assert result == pytest.approx(case.expected, rel=case.rel_tol, abs=case.abs_tol) + + +@pytest.mark.parametrize( + "case", + KINEMATIC_VISCOSITY_CASES, + ids=[c.case_id for c in KINEMATIC_VISCOSITY_CASES], +) +def test_kinematic_viscosity_oracle(case: NonlinearBlendCase) -> None: + result = kinematic_viscosity(case.values, case.vol_fractions) + assert result == pytest.approx(case.expected, rel=case.rel_tol, abs=case.abs_tol) diff --git a/tests/callbacks/test_callbacks.py b/tests/callbacks/test_callbacks.py new file mode 100644 index 0000000..20b1629 --- /dev/null +++ b/tests/callbacks/test_callbacks.py @@ -0,0 +1,211 @@ +"""Tests for training callbacks.""" + +from __future__ import annotations + +import copy + +import pytest +import torch +from torch.utils.data import DataLoader + +from ecnet import ECNet +from ecnet.callbacks import Callback, CallbackOperator, LRDecayLinear, Validator +from ecnet.datasets.structs import QSPRDatasetFromValues + + +class _HaltCallback(Callback): + """Returns False from every hook so CallbackOperator short-circuits.""" + + def on_train_begin(self): + return False + + def on_train_end(self): + return False + + def on_epoch_begin(self, epoch): + return False + + def on_epoch_end(self, epoch): + return False + + def on_batch_begin(self, batch): + return False + + def on_batch_end(self, batch): + return False + + def on_loss_begin(self, batch): + return False + + def on_loss_end(self, batch): + return False + + def on_step_begin(self, batch): + return False + + def on_step_end(self, batch): + return False + + +def test_callback_operator_short_circuits_on_false() -> None: + op = CallbackOperator() + op.add_cb(_HaltCallback()) + assert op.on_train_begin() is False + assert op.on_train_end() is False + assert op.on_epoch_begin(0) is False + assert op.on_epoch_end(0) is False + assert op.on_batch_begin(0) is False + assert op.on_batch_end(0) is False + assert op.on_loss_begin(0) is False + assert op.on_loss_end(0) is False + assert op.on_step_begin(0) is False + assert op.on_step_end(0) is False + + +def test_lrlineardecay() -> None: + model = torch.nn.Sequential( + torch.nn.Linear(3, 5), + torch.nn.ReLU(), + torch.nn.Linear(5, 1), + ) + lr = 0.001 + lrd = 0.00001 + optim = torch.optim.Adam(model.parameters(), lr=lr) + linear_decay = LRDecayLinear(lr, lrd, optim) + reached_epoch = 0 + for epoch in range(10000): + if not linear_decay.on_epoch_begin(epoch): + break + reached_epoch += 1 + if reached_epoch > int(lr / lrd): + raise RuntimeError(f"Linear decay: epoch reached {reached_epoch}") + + +def _synthetic_loader(n_samples: int = 4, n_features: int = 3) -> DataLoader: + desc_vals = [[float(i + j) for j in range(n_features)] for i in range(n_samples)] + target_vals = [[float(i)] for i in range(n_samples)] + ds = QSPRDatasetFromValues(desc_vals, target_vals) + return DataLoader(ds, batch_size=n_samples, shuffle=False) + + +def test_validator_patience_stops_when_loss_does_not_improve() -> None: + """Non-improving validation loss must trip patience (strict ``<`` best).""" + loader = _synthetic_loader() + net = ECNet(input_dim=3, output_dim=1, hidden_dim=4, n_hidden=1) + # Freeze weights so validation MSE is constant after the first eval. + for param in net.parameters(): + param.requires_grad_(False) + net.eval() + + patience = 2 + eval_iter = 1 + validator = Validator(loader, net, eval_iter=eval_iter, patience=patience) + + # epoch 0: establishes best_loss + assert validator.on_epoch_end(0) is True + assert validator._epoch_since_best == 0 + + # epochs 1..patience: accumulate; still continue (``>`` not ``>=``) + for epoch in range(1, patience + 1): + assert validator.on_epoch_end(epoch) is True + assert validator._epoch_since_best == patience + + # next eval: epoch_since_best > patience → halt + assert validator.on_epoch_end(patience + 1) is False + + +def test_validator_on_train_end_restores_best_state() -> None: + loader = _synthetic_loader() + net = ECNet(input_dim=3, output_dim=1, hidden_dim=4, n_hidden=1) + validator = Validator(loader, net, eval_iter=1, patience=2) + + assert validator.on_epoch_end(0) is True + best = copy.deepcopy(validator.best_state) + + # Mutate weights after the best checkpoint was recorded. + with torch.no_grad(): + for param in net.parameters(): + param.add_(1.0) + + mutated = {k: v.clone() for k, v in net.state_dict().items()} + for key in best: + assert not torch.equal(mutated[key], best[key]) + + assert validator.on_train_end() is True + restored = net.state_dict() + for key in best: + assert torch.equal(restored[key], best[key]) + + +def test_ecnet_fit_validator_wiring_early_stop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``valid_size > 0`` wires Validator into ``fit`` and can halt training.""" + desc_vals = [ + [0.0, 0.1, 0.2], + [0.2, 0.3, 0.4], + [0.4, 0.5, 0.6], + [0.6, 0.7, 0.8], + [0.8, 0.9, 1.0], + [1.0, 1.1, 1.2], + ] + target_vals = [[1.0], [2.0], [3.0], [4.0], [5.0], [6.0]] + ds = QSPRDatasetFromValues(desc_vals, target_vals) + net = ECNet(input_dim=3, output_dim=1, hidden_dim=8, n_hidden=1) + + # Keep real epoch-0 bookkeeping; force halt on the next eval so the fit + # path is deterministic (patience unit tests cover non-improving loss). + original = Validator.on_epoch_end + + def _halt_after_epoch_zero(self, epoch: int) -> bool: + if epoch == 0: + return original(self, epoch) + return False + + monkeypatch.setattr(Validator, "on_epoch_end", _halt_after_epoch_zero) + + epochs = 20 + train_losses, valid_losses = net.fit( + dataset=ds, + epochs=epochs, + batch_size=2, + valid_size=0.5, + valid_eval_iter=1, + patience=16, + random_state=0, + shuffle=False, + ) + + assert len(train_losses) == len(valid_losses) + assert len(train_losses) < epochs + assert len(train_losses) == 2 + # Epoch-0 validation must be evaluated before losses are recorded. + assert all(float(v) < 1e18 for v in valid_losses) + assert all(float(v) == float(v) for v in valid_losses) + + +def test_ecnet_fit_records_finite_valid_loss_from_epoch_zero() -> None: + """Verbose/history valid loss must not leak the unset-loss sentinel.""" + desc_vals = [ + [0.0, 0.1, 0.2], + [0.2, 0.3, 0.4], + [0.4, 0.5, 0.6], + [0.6, 0.7, 0.8], + [0.8, 0.9, 1.0], + [1.0, 1.1, 1.2], + ] + target_vals = [[1.0], [2.0], [3.0], [4.0], [5.0], [6.0]] + ds = QSPRDatasetFromValues(desc_vals, target_vals) + net = ECNet(input_dim=3, output_dim=1, hidden_dim=8, n_hidden=1) + _, valid_losses = net.fit( + dataset=ds, + epochs=3, + batch_size=2, + valid_size=0.5, + valid_eval_iter=1, + patience=16, + random_state=0, + shuffle=False, + ) + assert len(valid_losses) == 3 + assert all(float(v) < 1e18 for v in valid_losses) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..949ba59 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +"""Shared pytest configuration for the ECNet test suite. + +Marker registration lives in ``pyproject.toml`` under ``[tool.pytest.ini_options]``. +""" + +from __future__ import annotations + +import pytest + +_PROPS = ["bp", "cn", "cp", "kv", "lhv", "mon", "pp", "ron", "ysi", "mp"] +_BACKEND = "padel" +_N_DESC = 1875 +_N_PROCESSES = 1 +_EPOCHS = 10 + + +@pytest.fixture(scope="session") +def props() -> list[str]: + return list(_PROPS) + + +@pytest.fixture(scope="session") +def backend() -> str: + return _BACKEND + + +@pytest.fixture(scope="session") +def n_desc() -> int: + return _N_DESC + + +@pytest.fixture(scope="session") +def n_processes() -> int: + return _N_PROCESSES + + +@pytest.fixture(scope="session") +def epochs() -> int: + return _EPOCHS diff --git a/tests/datasets/test_lazy_backend_imports.py b/tests/datasets/test_lazy_backend_imports.py new file mode 100644 index 0000000..8bbcae5 --- /dev/null +++ b/tests/datasets/test_lazy_backend_imports.py @@ -0,0 +1,18 @@ +"""PaDEL loaders must not import alvadescpy at package import time.""" + +from __future__ import annotations + +import sys + + +def test_importing_datasets_does_not_import_alvadescpy() -> None: + for name in list(sys.modules): + if name == "alvadescpy" or name.startswith("alvadescpy."): + del sys.modules[name] + if name == "ecnet.datasets" or name.startswith("ecnet.datasets."): + del sys.modules[name] + + import ecnet.datasets # noqa: F401 + from ecnet.datasets import load_cp # noqa: F401 + + assert "alvadescpy" not in sys.modules diff --git a/tests/datasets/test_load_data.py b/tests/datasets/test_load_data.py new file mode 100644 index 0000000..8e85be3 --- /dev/null +++ b/tests/datasets/test_load_data.py @@ -0,0 +1,52 @@ +"""Tests for bundled property file loading helpers.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from ecnet.datasets.load_data import ( + _DATA_PATH, + _get_file_data, + _get_prop_paths, + _open_smiles_file, + _open_target_file, +) + + +def test_open_smiles_file(tmp_path: Path) -> None: + smiles_text = "CCC\nCCCC\nCCCCC" + smiles_path = tmp_path / "sample.smiles" + smiles_path.write_text(smiles_text) + smiles = smiles_text.split("\n") + opened_smiles = _open_smiles_file(str(smiles_path)) + assert len(smiles) == len(opened_smiles) + for i in range(len(smiles)): + assert smiles[i] == opened_smiles[i] + + +def test_open_target_file(tmp_path: Path) -> None: + target_text = "3.0\n4.0\n5.0" + target_path = tmp_path / "sample.target" + target_path.write_text(target_text) + target_vals = [[float(v)] for v in target_text.split("\n")] + opened_targets = _open_target_file(str(target_path)) + assert len(target_vals) == len(opened_targets) + for i in range(len(target_vals)): + assert target_vals[i] == opened_targets[i] + + +def test_get_prop_paths(props: list[str]) -> None: + for p in props: + smiles_fn, target_fn = _get_prop_paths(p) + assert os.path.join(_DATA_PATH, f"{p}.smiles") == smiles_fn + assert os.path.join(_DATA_PATH, f"{p}.target") == target_fn + + +def test_get_file_data(props: list[str]) -> None: + for p in props: + smiles, targets = _get_file_data(p) + assert len(smiles) == len(targets) + assert type(smiles[0]) is str + assert type(targets[0]) is list + assert type(targets[0][0]) is float diff --git a/tests/datasets/test_loaders.py b/tests/datasets/test_loaders.py new file mode 100644 index 0000000..47f1073 --- /dev/null +++ b/tests/datasets/test_loaders.py @@ -0,0 +1,84 @@ +"""Smoke tests for public ``load_*`` property loaders (design §8.1–§8.2). + +Default backend is ``padel`` only; alvaDesc is not exercised in CI (Q10). +""" + +from __future__ import annotations + +import pytest + +from ecnet.datasets import ( + QSPRDatasetFromFile, + load_bp, + load_cn, + load_cp, + load_kv, + load_lhv, + load_mon, + load_mp, + load_pp, + load_ron, + load_ysi, +) +from ecnet.datasets import load_data as load_data_mod + +_LOADERS = [ + (load_bp, "bp"), + (load_cn, "cn"), + (load_cp, "cp"), + (load_kv, "kv"), + (load_lhv, "lhv"), + (load_mon, "mon"), + (load_mp, "mp"), + (load_pp, "pp"), + (load_ron, "ron"), + (load_ysi, "ysi"), +] + + +@pytest.mark.parametrize( + ("loader", "prop"), + _LOADERS, + ids=[prop for _, prop in _LOADERS], +) +def test_load_prop_as_tuple(loader, prop: str) -> None: + smiles, targets = loader(as_dataset=False) + assert len(smiles) == len(targets) + assert len(smiles) > 0 + assert type(smiles[0]) is str + assert type(targets[0]) is list + assert type(targets[0][0]) is float + + +@pytest.mark.parametrize( + ("loader", "prop"), + _LOADERS, + ids=[prop for _, prop in _LOADERS], +) +def test_load_prop_as_dataset_routes_to_load_set( + loader, prop: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """All ten loaders must call ``_load_set(prop, 'padel')`` by default.""" + calls: list[tuple[str, str]] = [] + sentinel = object() + + def _fake_load_set(p: str, backend: str): + calls.append((p, backend)) + return sentinel + + monkeypatch.setattr(load_data_mod, "_load_set", _fake_load_set) + result = loader(as_dataset=True) + assert result is sentinel + assert calls == [(prop, "padel")] + + +@pytest.mark.integration +def test_load_pp_as_dataset_padel_smoke(n_desc: int) -> None: + """Real PaDEL smoke on the smallest bundled set (pour point, 40 SMILES).""" + ds = load_pp(as_dataset=True) + assert isinstance(ds, QSPRDatasetFromFile) + assert len(ds.smiles) == len(ds.target_vals) + assert len(ds.smiles) > 0 + assert len(ds.desc_vals) == len(ds.smiles) + assert len(ds.desc_vals[0]) == n_desc + assert len(ds.desc_names) == n_desc diff --git a/tests/datasets/test_structs.py b/tests/datasets/test_structs.py new file mode 100644 index 0000000..e510c1d --- /dev/null +++ b/tests/datasets/test_structs.py @@ -0,0 +1,85 @@ +"""Tests for QSPR dataset structures.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import torch + +from ecnet.datasets.structs import ( + QSPRDataset, + QSPRDatasetFromFile, + QSPRDatasetFromValues, +) + + +def test_qsprdataset(backend: str, n_desc: int) -> None: + smiles = ["CCC", "CCCC", "CCCCC"] + targets = [[3.0], [4.0], [5.0]] + ds = QSPRDataset(smiles, targets, backend=backend) + assert len(ds.smiles) == len(smiles) + assert len(ds.target_vals) == len(targets) + assert len(ds.target_vals[0]) == len(targets[0]) + assert len(ds.desc_vals) == len(smiles) + assert len(ds.desc_vals[0]) == n_desc + assert isinstance(ds.desc_vals, torch.Tensor) + assert len(ds.desc_names) == n_desc + + +def test_qsprdatasetfromfile(tmp_path: Path, backend: str, n_desc: int) -> None: + smiles_text = "CCC\nCCCC\nCCCCC" + smiles_path = tmp_path / "sample.smiles" + smiles_path.write_text(smiles_text) + smiles = smiles_text.split("\n") + targets = [[3.0], [4.0], [5.0]] + ds = QSPRDatasetFromFile(str(smiles_path), targets, backend=backend) + assert len(ds.smiles) == len(smiles) + assert len(ds.target_vals) == len(targets) + assert len(ds.target_vals[0]) == len(targets[0]) + assert len(ds.desc_vals) == len(smiles) + assert len(ds.desc_vals[0]) == n_desc + assert isinstance(ds.desc_vals, torch.Tensor) + assert len(ds.desc_names) == n_desc + + +def test_qsprdatasetfromvalues() -> None: + desc_vals = [ + [0.0, 0.1, 0.2, 0.3], + [0.0, 0.2, 0.3, 0.1], + [0.1, 0.3, 0.0, 0.2], + ] + target_vals = [[1.0], [2.0], [3.0]] + ds = QSPRDatasetFromValues(desc_vals, target_vals) + assert len(ds.smiles) == len(desc_vals) + assert len(ds.desc_names) == len(desc_vals[0]) + assert len(ds.desc_vals) == len(desc_vals) + assert len(ds.target_vals) == len(target_vals) + assert len(ds.target_vals[0]) == len(target_vals[0]) + assert isinstance(ds.desc_vals, torch.Tensor) + assert isinstance(ds.target_vals, torch.Tensor) + + +def test_qsprdataset_unknown_backend_raises() -> None: + with pytest.raises(ValueError, match="Unknown backend"): + QSPRDataset(["CCC"], [[1.0]], backend="not-a-backend") + + +def test_qsprdataset_set_index_and_set_desc_index() -> None: + desc_vals = [ + [0.0, 0.1, 0.2, 0.3], + [1.0, 1.1, 1.2, 1.3], + [2.0, 2.1, 2.2, 2.3], + ] + target_vals = [[10.0], [20.0], [30.0]] + ds = QSPRDatasetFromValues(desc_vals, target_vals) + ds.set_index([0, 2]) + assert len(ds) == 2 + assert ds.target_vals[0].tolist() == [10.0] + assert ds.target_vals[1].tolist() == [30.0] + assert ds.desc_vals.shape[0] == 2 + + ds.set_desc_index([1, 3]) + assert ds.desc_vals.shape[1] == 2 + assert len(ds.desc_names) == 2 + assert ds.desc_vals[0].tolist() == pytest.approx([0.1, 0.3]) diff --git a/tests/datasets/test_utils.py b/tests/datasets/test_utils.py new file mode 100644 index 0000000..b52100e --- /dev/null +++ b/tests/datasets/test_utils.py @@ -0,0 +1,12 @@ +"""Tests for dataset descriptor utilities.""" + +from ecnet.datasets.utils import _qspr_from_padel + + +def test_dataset_utils(n_desc: int) -> None: + smiles = ["CCC", "CCCC", "CCCCC"] + desc, keys = _qspr_from_padel(smiles) + assert len(keys) == n_desc + assert len(desc) == 3 + for d in desc: + assert len(d) == n_desc diff --git a/tests/fixtures/.gitkeep b/tests/fixtures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..4a28257 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,30 @@ +# Test fixtures + +Numeric and file fixtures for ECNet characterization tests live here. + +## Oracle labeling + +Label every fixture (in the fixture module docstring, filename, or adjacent +comment) with exactly one of the following classes: + +| Class | Meaning | Typical use | +|-------|---------|-------------| +| **Self-consistency** | Expected values from an independent reimplementation of the same equations already in `ecnet`, or from hand evaluation of those formulas on fixed inputs | Blend algebraic checks; error-propagation helpers | +| **Literature** | Values taken from a cited paper, table, or dataset card (with DOI or bibliographic key) | Cross-checks against published blend or property tables | + +Do not mix classes in one fixture without stating which entries are which. +Regression anchors that only freeze current library output (no independent +derivation) should be labeled **self-consistency / regression** and must not be +presented as literature measurements. + +## Tolerances + +Record `rel` / `abs` for `pytest.approx` (or equivalent) next to each expected +value, with a short justification (algebraic exactness, float round-trip, or +reported experimental uncertainty from the source). + +## Layout + +Prefer small Python modules or data files under this directory, imported by +tests under `tests/blends/`, `tests/datasets/`, and related packages. Keep +PaDEL-backed integration inputs minimal; default CI assumes the PaDEL path only. diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/blend_errors.py b/tests/fixtures/blend_errors.py new file mode 100644 index 0000000..1d7211a --- /dev/null +++ b/tests/fixtures/blend_errors.py @@ -0,0 +1,122 @@ +"""Self-consistency fixtures for blend error-propagation helpers. + +Oracle class: **self-consistency** — hand evaluation of the formulas in +``ecnet.blends.equations`` (not literature table lookups). Expected values are +computed independently of ``ecnet``; ``abs=1e-12`` covers float ``sqrt`` noise. +""" + +from __future__ import annotations + +from math import sqrt +from typing import NamedTuple + + +class LinearErrCase(NamedTuple): + case_id: str + errors: list[float] + proportions: list[float] + expected: float + abs_tol: float + note: str + + +class ExponentialErrCase(NamedTuple): + case_id: str + values: list[float] + result: float + errors: list[float] + proportions: list[float] + a: float + b: float + expected: float + abs_tol: float + note: str + + +class KvErrCase(NamedTuple): + case_id: str + values: list[float] + errors: list[float] + proportions: list[float] + expected: float + abs_tol: float + note: str + + +_ABS = 1e-12 + +# linear_blend_err: sqrt(sum((e_i * V_i)^2)) +LINEAR_ERR_CASES: list[LinearErrCase] = [ + LinearErrCase( + case_id="linear_single", + errors=[2.0], + proportions=[1.0], + expected=2.0, # sqrt((2*1)^2) + abs_tol=_ABS, + note="Single-component identity", + ), + LinearErrCase( + case_id="linear_binary", + errors=[1.0, 2.0], + proportions=[0.5, 0.5], + # sqrt((0.5)^2 + (1.0)^2) = sqrt(0.25 + 1.0) = sqrt(1.25) + expected=sqrt(1.25), + abs_tol=_ABS, + note="Equal binary proportions", + ), +] + +# exponential_blend_err: sqrt(sum(((f*b*e_i/x_i)*V_i)^2)); a is unused in body +EXPONENTIAL_ERR_CASES: list[ExponentialErrCase] = [ + ExponentialErrCase( + case_id="exp_single", + values=[10.0], + result=8.0, + errors=[0.5], + proportions=[1.0], + a=1.0, + b=2.0, + # |(8*2*0.5/10)*1| = 0.8 + expected=0.8, + abs_tol=_ABS, + note="Single-component; a unused by implementation", + ), + ExponentialErrCase( + case_id="exp_binary", + values=[10.0, 20.0], + result=12.0, + errors=[1.0, 2.0], + proportions=[0.25, 0.75], + a=1.0, + b=13.45, + # term0: (12*13.45*1/10)*0.25 = 4.035 + # term1: (12*13.45*2/20)*0.75 = 12.105 + # sqrt(4.035^2 + 12.105^2) + expected=sqrt(4.035**2 + 12.105**2), + abs_tol=_ABS, + note="Binary unequal; b chosen like CP exponent for numeric variety", + ), +] + +# kv_error: sqrt(sum((V_i * e_i / x_i)^2)) +KV_ERR_CASES: list[KvErrCase] = [ + KvErrCase( + case_id="kv_err_single", + values=[2.0], + errors=[0.4], + proportions=[1.0], + expected=0.2, # 1*0.4/2 + abs_tol=_ABS, + note="Single-component identity", + ), + KvErrCase( + case_id="kv_err_binary", + values=[2.0, 4.0], + errors=[0.2, 0.8], + proportions=[0.5, 0.5], + # terms: 0.5*0.2/2 = 0.05; 0.5*0.8/4 = 0.1; sqrt(0.05^2 + 0.1^2) + expected=sqrt(0.05**2 + 0.1**2), + abs_tol=_ABS, + note="Equal binary proportions", + ), +] diff --git a/tests/fixtures/linear_blends.py b/tests/fixtures/linear_blends.py new file mode 100644 index 0000000..4147bab --- /dev/null +++ b/tests/fixtures/linear_blends.py @@ -0,0 +1,56 @@ +"""Self-consistency fixtures for linear volume-fraction blend averages. + +Oracle class: **self-consistency** (hand evaluation of ``sum(V_i * x_i)``). +These are not literature table values. + +The public predictors ``cetane_number``, ``yield_sooting_index``, and +``lower_heating_value`` all use this linear mixing rule (see ``ecnet.blends.predict`` +docstrings for NREL / DOI citations). Expected values below are computed +independently of ``ecnet``; tolerances assume algebraic float summation. +""" + +from __future__ import annotations + +from typing import NamedTuple + + +class LinearBlendCase(NamedTuple): + """One linear blend oracle case.""" + + case_id: str + values: list[float] + vol_fractions: list[float] + expected: float + abs_tol: float + note: str + + +# abs=1e-12: exact weighted sums for these inputs within double float noise. +_ABS = 1e-12 + +LINEAR_BLEND_CASES: list[LinearBlendCase] = [ + LinearBlendCase( + case_id="binary_equal", + values=[40.0, 60.0], + vol_fractions=[0.5, 0.5], + expected=50.0, # 0.5*40 + 0.5*60 + abs_tol=_ABS, + note="Equal binary blend", + ), + LinearBlendCase( + case_id="ternary_unequal", + values=[30.0, 50.0, 70.0], + vol_fractions=[0.5, 0.3, 0.2], + expected=44.0, # 0.5*30 + 0.3*50 + 0.2*70 = 15 + 15 + 14 + abs_tol=_ABS, + note="Unequal ternary blend; fractions sum to 1.0", + ), + LinearBlendCase( + case_id="single_component", + values=[55.0], + vol_fractions=[1.0], + expected=55.0, + abs_tol=_ABS, + note="Identity: single component with volume fraction 1.0", + ), +] diff --git a/tests/fixtures/nonlinear_blends.py b/tests/fixtures/nonlinear_blends.py new file mode 100644 index 0000000..fb97593 --- /dev/null +++ b/tests/fixtures/nonlinear_blends.py @@ -0,0 +1,125 @@ +"""Self-consistency fixtures for cloud point and kinematic viscosity blends. + +Oracle class: **self-consistency** — independent stdlib reimplementation of the +equations documented in ``ecnet.blends.predict`` (not literature table lookups, +and not imported from ``ecnet.blends.equations``). + +Units +----- +- Cloud point I/O: °C (internal Rankine conversion in the oracle mirrors Semwal) +- Kinematic viscosity I/O: cSt (Ding et al. mixing rule, equation 8) + +Tolerances use ``rel=1e-12`` and ``abs=1e-12`` for float ``**`` / ``log`` / ``exp``. +""" + +from __future__ import annotations + +from math import exp, log +from typing import NamedTuple + + +class NonlinearBlendCase(NamedTuple): + """One nonlinear blend oracle case.""" + + case_id: str + values: list[float] + vol_fractions: list[float] + expected: float + rel_tol: float + abs_tol: float + note: str + + +_REL = 1e-12 +_ABS = 1e-12 + + +def _celsius_to_rankine(temp_c: float) -> float: + return (9 / 5) * temp_c + 491.67 + + +def _rankine_to_celsius(temp_r: float) -> float: + return (temp_r - 491.67) * (1 / (9 / 5)) + + +def _oracle_cloud_point_c(values_c: list[float], vol_fractions: list[float]) -> float: + """Semwal-style CP blend; inputs/outputs in °C, power sum in Rankine.""" + cp_sum = 0.0 + for idx, val in enumerate(values_c): + cp_sum += vol_fractions[idx] * _celsius_to_rankine(val) ** 13.45 + return _rankine_to_celsius(cp_sum ** (1 / 13.45)) + + +def _oracle_kinematic_viscosity_cst( + values_cst: list[float], vol_fractions: list[float] +) -> float: + """Ding et al. equation 8; kinematic viscosity in cSt.""" + kv_sum = 0.0 + for idx, val in enumerate(values_cst): + kv_sum += vol_fractions[idx] / log(2000 * val) + return exp(1 / kv_sum) / 2000 + + +def _case( + case_id: str, + values: list[float], + vol_fractions: list[float], + expected: float, + note: str, +) -> NonlinearBlendCase: + return NonlinearBlendCase( + case_id=case_id, + values=values, + vol_fractions=vol_fractions, + expected=expected, + rel_tol=_REL, + abs_tol=_ABS, + note=note, + ) + + +# Cloud point (°C): Semwal et al. diesel blending model (see predict.py docstring). +_CP_SINGLE_VALUES = [5.0] +_CP_SINGLE_VOLS = [1.0] +_CP_BINARY_VALUES = [-10.0, 20.0] +_CP_BINARY_VOLS = [0.3, 0.7] + +CLOUD_POINT_CASES: list[NonlinearBlendCase] = [ + _case( + "cp_single_component_celsius", + _CP_SINGLE_VALUES, + _CP_SINGLE_VOLS, + _oracle_cloud_point_c(_CP_SINGLE_VALUES, _CP_SINGLE_VOLS), + "Identity in °C; exercises Rankine round-trip consistency", + ), + _case( + "cp_binary_unequal_celsius", + _CP_BINARY_VALUES, + _CP_BINARY_VOLS, + _oracle_cloud_point_c(_CP_BINARY_VALUES, _CP_BINARY_VOLS), + "Binary CP blend; values and result in °C", + ), +] + +# Kinematic viscosity (cSt): Ding et al. equation 8. +_KV_SINGLE_VALUES = [2.5] +_KV_SINGLE_VOLS = [1.0] +_KV_BINARY_VALUES = [1.5, 4.0] +_KV_BINARY_VOLS = [0.4, 0.6] + +KINEMATIC_VISCOSITY_CASES: list[NonlinearBlendCase] = [ + _case( + "kv_single_component_cst", + _KV_SINGLE_VALUES, + _KV_SINGLE_VOLS, + _oracle_kinematic_viscosity_cst(_KV_SINGLE_VALUES, _KV_SINGLE_VOLS), + "Identity in cSt", + ), + _case( + "kv_binary_unequal_cst", + _KV_BINARY_VALUES, + _KV_BINARY_VOLS, + _oracle_kinematic_viscosity_cst(_KV_BINARY_VALUES, _KV_BINARY_VOLS), + "Binary KV blend; values and result in cSt", + ), +] diff --git a/tests/model/test_model.py b/tests/model/test_model.py new file mode 100644 index 0000000..aa7828c --- /dev/null +++ b/tests/model/test_model.py @@ -0,0 +1,124 @@ +"""Tests for ECNet model construct, fit, and save/load.""" + +from __future__ import annotations + +import math +from pathlib import Path + +import pytest +import torch + +from ecnet import ECNet +from ecnet.datasets.structs import QSPRDatasetFromValues +from ecnet.model import load_model + +_SEED = 0 +_INPUT_DIM = 4 +_EPOCHS = 5 + + +def _synthetic_dataset() -> QSPRDatasetFromValues: + desc_vals = [ + [0.0, 0.1, 0.2, 0.3], + [0.1, 0.2, 0.3, 0.4], + [0.2, 0.3, 0.4, 0.5], + [0.3, 0.4, 0.5, 0.6], + ] + target_vals = [[1.0], [2.0], [3.0], [4.0]] + return QSPRDatasetFromValues(desc_vals, target_vals) + + +def test_model_construct() -> None: + input_dim = 3 + output_dim = 1 + hidden_dim = 5 + n_hidden = 2 + net = ECNet(input_dim, output_dim, hidden_dim, n_hidden) + assert len(net.model) == 2 + n_hidden + assert net.model[0].in_features == input_dim + assert net.model[0].out_features == hidden_dim + assert net.model[-1].in_features == hidden_dim + assert net.model[-1].out_features == output_dim + for layer in net.model[1:-1]: + assert layer.in_features == hidden_dim + assert layer.out_features == hidden_dim + + +def test_model_fit_seeded_finite_losses() -> None: + torch.manual_seed(_SEED) + ds = _synthetic_dataset() + net = ECNet(_INPUT_DIM, 1, 16, 1) + tr_loss, val_loss = net.fit( + dataset=ds, + epochs=_EPOCHS, + batch_size=2, + random_state=_SEED, + shuffle=False, + ) + assert len(tr_loss) == len(val_loss) == _EPOCHS + assert all(math.isfinite(float(v)) for v in tr_loss) + # valid_size default 0.0 → placeholder zeros + assert all(float(v) == 0.0 for v in val_loss) + + +def _trained_net() -> tuple[ECNet, QSPRDatasetFromValues]: + torch.manual_seed(_SEED) + ds = _synthetic_dataset() + net = ECNet(_INPUT_DIM, 1, 16, 1) + net.fit( + dataset=ds, + epochs=_EPOCHS, + batch_size=2, + random_state=_SEED, + shuffle=False, + ) + return net, ds + + +def test_model_save_load_roundtrip_state_dict(tmp_path: Path) -> None: + net, ds = _trained_net() + + with pytest.raises(ValueError): + net.save(str(tmp_path / "model.badext")) + + model_path = tmp_path / "model.pt" + net.save(str(model_path)) + assert model_path.is_file() + + payload = torch.load(model_path, map_location="cpu", weights_only=False) + assert isinstance(payload, dict) + assert payload["format"] == "ecnet-state-v1" + + net.eval() + x = ds[0]["desc_vals"] + val_0 = net(x) + + with pytest.raises(FileNotFoundError): + load_model(str(tmp_path / "missing.pt")) + + loaded = load_model(str(model_path)) + loaded.eval() + assert torch.equal(val_0, loaded(x)) + + +def test_model_load_legacy_full_module_pickle(tmp_path: Path) -> None: + net, ds = _trained_net() + legacy_path = tmp_path / "legacy.pt" + # Simulate pre-shim checkpoints that pickled the whole module. + torch.save(net, legacy_path) + + net.eval() + x = ds[0]["desc_vals"] + expected = net(x) + + loaded = load_model(str(legacy_path)) + loaded.eval() + assert isinstance(loaded, ECNet) + assert torch.equal(expected, loaded(x)) + + +def test_model_load_unrecognized_payload_raises(tmp_path: Path) -> None: + bad_path = tmp_path / "bad.pt" + torch.save({"format": "not-ecnet"}, bad_path) + with pytest.raises(ValueError, match="Unrecognized ECNet checkpoint"): + load_model(str(bad_path)) diff --git a/tests/tasks/test_feature_selection.py b/tests/tasks/test_feature_selection.py new file mode 100644 index 0000000..5f92891 --- /dev/null +++ b/tests/tasks/test_feature_selection.py @@ -0,0 +1,33 @@ +"""Tests for random-forest feature selection.""" + +from __future__ import annotations + +from ecnet.datasets.structs import QSPRDatasetFromValues +from ecnet.tasks.feature_selection import select_rfr + + +def _synthetic_dataset( + n_samples: int = 8, n_features: int = 20 +) -> QSPRDatasetFromValues: + desc_vals = [ + [float((i + 1) * (j + 1) % 7) for j in range(n_features)] + for i in range(n_samples) + ] + target_vals = [[float(i)] for i in range(n_samples)] + return QSPRDatasetFromValues(desc_vals, target_vals) + + +def test_select_rfr_structure_and_ordering() -> None: + ds = _synthetic_dataset() + n_features = len(ds.desc_vals[0]) + indices, importances = select_rfr(ds, total_importance=0.90, random_state=0) + + assert isinstance(indices, list) + assert isinstance(importances, list) + assert len(indices) == len(importances) + # Implementation slices with exclusive cutoff, so the selected set is a + # proper subset of all features when importances are non-degenerate. + assert len(indices) < n_features + assert importances == sorted(importances, reverse=True) + for index in indices: + assert 0 <= index < n_features diff --git a/tests/tasks/test_parameter_tuning.py b/tests/tasks/test_parameter_tuning.py new file mode 100644 index 0000000..550cc6e --- /dev/null +++ b/tests/tasks/test_parameter_tuning.py @@ -0,0 +1,59 @@ +"""Tests for ABC-based hyperparameter tuning helpers.""" + +from __future__ import annotations + +from ecnet.datasets.structs import QSPRDatasetFromValues +from ecnet.tasks.parameter_tuning import ( + CONFIG, + tune_batch_size, + tune_model_architecture, + tune_training_parameters, +) + + +def _train_eval_datasets() -> tuple[QSPRDatasetFromValues, QSPRDatasetFromValues]: + desc_train = [ + [0.0, 0.1, 0.2, 0.3], + [0.1, 0.2, 0.3, 0.4], + [0.2, 0.3, 0.4, 0.5], + [0.3, 0.4, 0.5, 0.6], + [0.4, 0.5, 0.6, 0.7], + [0.5, 0.6, 0.7, 0.8], + ] + target_train = [[1.0], [2.0], [3.0], [4.0], [5.0], [6.0]] + desc_eval = [ + [0.15, 0.25, 0.35, 0.45], + [0.55, 0.65, 0.75, 0.85], + ] + target_eval = [[2.5], [5.5]] + return ( + QSPRDatasetFromValues(desc_train, target_train), + QSPRDatasetFromValues(desc_eval, target_eval), + ) + + +def test_tune_batch_size_keys_and_bounds(n_processes: int) -> None: + ds_train, ds_eval = _train_eval_datasets() + res = tune_batch_size(1, 1, ds_train, ds_eval, n_processes, epochs=2, patience=2) + assert set(res.keys()) == {"batch_size"} + assert 1 <= res["batch_size"] <= len(ds_train.target_vals) + + +def test_tune_model_architecture_keys_and_bounds(n_processes: int) -> None: + ds_train, ds_eval = _train_eval_datasets() + res = tune_model_architecture( + 1, 1, ds_train, ds_eval, n_processes, epochs=2, patience=2 + ) + assert set(res.keys()) == {"hidden_dim", "n_hidden", "dropout"} + for key, (lo, hi) in CONFIG["architecture_params_range"].items(): + assert lo <= res[key] <= hi + + +def test_tune_training_parameters_keys_and_bounds(n_processes: int) -> None: + ds_train, ds_eval = _train_eval_datasets() + res = tune_training_parameters( + 1, 1, ds_train, ds_eval, n_processes, epochs=2, patience=2 + ) + assert set(res.keys()) == {"lr", "lr_decay"} + for key, (lo, hi) in CONFIG["training_params_range"].items(): + assert lo <= res[key] <= hi diff --git a/tests/test_all.py b/tests/test_all.py deleted file mode 100644 index 1a5b336..0000000 --- a/tests/test_all.py +++ /dev/null @@ -1,240 +0,0 @@ -import torch -import pytest -import os - -from ecnet.datasets.structs import QSPRDataset, QSPRDatasetFromFile, QSPRDatasetFromValues -from ecnet.datasets.utils import _qspr_from_padel -from ecnet.datasets.load_data import _open_smiles_file, _open_target_file, _get_prop_paths,\ - _DATA_PATH, _get_file_data -from ecnet.callbacks import LRDecayLinear -from ecnet import ECNet -from ecnet.model import load_model -from ecnet.tasks.feature_selection import select_rfr -from ecnet.tasks.parameter_tuning import tune_batch_size, tune_model_architecture,\ - tune_training_parameters, CONFIG - -_PROPS = ['bp', 'cn', 'cp', 'kv', 'lhv', 'mon', 'pp', 'ron', 'ysi', 'mp'] -_BACKEND = 'padel' -_N_DESC = 1875 -_N_PROCESSES = 1 -_EPOCHS = 10 - -# dataset utils - -def test_dataset_utils(): - smiles = ['CCC', 'CCCC', 'CCCCC'] - desc, keys = _qspr_from_padel(smiles) - assert len(keys) == _N_DESC - assert len(desc) == 3 - for d in desc: - assert len(d) == _N_DESC - -# dataset loading - -def test_open_smiles_file(): - smiles = 'CCC\nCCCC\nCCCCC' - with open('_temp.smiles', 'w') as smi_file: - smi_file.write(smiles) - smi_file.close() - smiles = smiles.split('\n') - opened_smiles = _open_smiles_file('_temp.smiles') - assert len(smiles) == len(opened_smiles) - for i in range(len(smiles)): - assert smiles[i] == opened_smiles[i] - - -def test_open_target_file(): - print('UNIT TEST: Open .target file') - target_vals = '3.0\n4.0\n5.0' - with open('_temp.target', 'w') as tar_file: - tar_file.write(target_vals) - tar_file.close() - target_vals = target_vals.split('\n') - target_vals = [[float(v)] for v in target_vals] - opened_targets = _open_target_file('_temp.target') - assert len(target_vals) == len(opened_targets) - for i in range(len(target_vals)): - assert target_vals[i] == opened_targets[i] - - -def test_get_prop_paths(): - for p in _PROPS: - smiles_fn, target_fn = _get_prop_paths(p) - assert os.path.join(_DATA_PATH, f'{p}.smiles') == smiles_fn - assert os.path.join(_DATA_PATH, f'{p}.target') == target_fn - - -def test_get_file_data(): - for p in _PROPS: - smiles, targets = _get_file_data(p) - assert len(smiles) == len(targets) - assert type(smiles[0]) == str - assert type(targets[0]) == list - assert type(targets[0][0]) == float - -# dataset structures - -def test_qsprdataset(): - smiles = ['CCC', 'CCCC', 'CCCCC'] - targets = [[3.0], [4.0], [5.0]] - ds = QSPRDataset(smiles, targets, backend=_BACKEND) - assert len(ds.smiles) == len(smiles) - assert len(ds.target_vals) == len(targets) - assert len(ds.target_vals[0]) == len(targets[0]) - assert len(ds.desc_vals) == len(smiles) - assert len(ds.desc_vals[0]) == _N_DESC - assert type(ds.desc_vals) == type(torch.tensor([])) - assert len(ds.desc_names) == _N_DESC - - -def test_qsprdatasetfromfile(): - smiles = 'CCC\nCCCC\nCCCCC' - with open('_temp.smiles', 'w') as smi_file: - smi_file.write(smiles) - smi_file.close() - smiles = smiles.split('\n') - targets = [[3.0], [4.0], [5.0]] - ds = QSPRDatasetFromFile('_temp.smiles', targets, backend=_BACKEND) - assert len(ds.smiles) == len(smiles) - assert len(ds.target_vals) == len(targets) - assert len(ds.target_vals[0]) == len(targets[0]) - assert len(ds.desc_vals) == len(smiles) - assert len(ds.desc_vals[0]) == _N_DESC - assert type(ds.desc_vals) == type(torch.tensor([])) - assert len(ds.desc_names) == _N_DESC - - -def test_qsprdatasetfromvalues(): - desc_vals = [ - [0.0, 0.1, 0.2, 0.3], - [0.0, 0.2, 0.3, 0.1], - [0.1, 0.3, 0.0, 0.2] - ] - target_vals = [[1.0], [2.0], [3.0]] - ds = QSPRDatasetFromValues(desc_vals, target_vals) - assert len(ds.smiles) == len(desc_vals) - assert len(ds.desc_names) == len(desc_vals[0]) - assert len(ds.desc_vals) == len(desc_vals) - assert len(ds.target_vals) == len(target_vals) - assert len(ds.target_vals[0]) == len(target_vals[0]) - assert type(ds.desc_vals) == type(torch.tensor([])) - assert type(ds.target_vals) == type(torch.tensor([])) - -# callbacks - -def test_lrlineardecay(): - model = torch.nn.Sequential( - torch.nn.Linear(3, 5), - torch.nn.ReLU(), - torch.nn.Linear(5, 1) - ) - lr = 0.001 - lrd = 0.00001 - optim = torch.optim.Adam(model.parameters(), lr=lr) - linear_decay = LRDecayLinear(lr, lrd, optim) - reached_epoch = 0 - for epoch in range(10000): - if not linear_decay.on_epoch_begin(epoch): - break - reached_epoch += 1 - if reached_epoch > int(lr / lrd): - raise RuntimeError('Linear decay: epoch reached {}'.format(reached_epoch)) - - -def test_validator(): - # I can't think of a good way to test this one, but it works in practice - return - -# model - -def test_model_construct(): - _INPUT_DIM = 3 - _OUTPUT_DIM = 1 - _HIDDEN_DIM = 5 - _N_HIDDEN = 2 - net = ECNet(_INPUT_DIM, _OUTPUT_DIM, _HIDDEN_DIM, _N_HIDDEN) - assert len(net.model) == 2 + _N_HIDDEN - assert net.model[0].in_features == _INPUT_DIM - assert net.model[0].out_features == _HIDDEN_DIM - assert net.model[-1].in_features == _HIDDEN_DIM - assert net.model[-1].out_features == _OUTPUT_DIM - for layer in net.model[1:-1]: - assert layer.in_features == _HIDDEN_DIM - assert layer.out_features == _HIDDEN_DIM - - -def test_model_fit(): - net = ECNet(_N_DESC, 1, 512, 2) - smiles = ['CCC', 'CCCC', 'CCCCC'] - targets = [[3.0], [4.0], [5.0]] - tr_loss, val_loss = net.fit(smiles, targets, backend=_BACKEND, epochs=_EPOCHS) - assert len(tr_loss) == len(val_loss) - assert len(tr_loss) == _EPOCHS - - -def test_model_save_load(): - net = ECNet(_N_DESC, 1, 512, 2) - smiles = ['CCC', 'CCCC', 'CCCCC'] - targets = [[3.0], [4.0], [5.0]] - ds = QSPRDataset(smiles, targets, backend=_BACKEND) - tr_loss, val_loss = net.fit(dataset=ds, epochs=_EPOCHS) - with pytest.raises(ValueError): - net.save('_test.badext') - net.save('_test.pt') - val_0 = net(ds[0]['desc_vals']) - with pytest.raises(FileNotFoundError): - net = load_model('badfile.pt') - net = load_model('_test.pt') - val_0_new = net(ds[0]['desc_vals']) - assert val_0 == val_0_new - -# tasks - -def test_feature_selection(): - smiles = ['CCC', 'CCCC', 'CCCCC'] - targets = [[3.0], [4.0], [5.0]] - ds = QSPRDataset(smiles, targets, backend=_BACKEND) - indices, importances = select_rfr(ds, total_importance=0.90) - assert len(indices) < _N_DESC - assert len(indices) == len(importances) - assert importances == sorted(importances, reverse=True) - for index in indices: - assert index < _N_DESC - - -def test_tune_batch_size(): - smiles = ['CCC', 'CCCC', 'CCCCCC'] - targets = [[3.0], [4.0], [6.0]] - ds_train = QSPRDataset(smiles, targets, backend=_BACKEND) - smiles = ['CCCCC'] - targets = [[5.0]] - ds_eval = QSPRDataset(smiles, targets, backend=_BACKEND) - model = ECNet(_N_DESC, 1, 5, 1) - res = tune_batch_size(1, 1, ds_train, ds_eval, _N_PROCESSES) - assert 1 <= res['batch_size'] <= len(ds_train.target_vals) - - -def test_tune_model_architecture(): - smiles = ['CCC', 'CCCC', 'CCCCCC'] - targets = [[3.0], [4.0], [6.0]] - ds_train = QSPRDataset(smiles, targets, backend=_BACKEND) - smiles = ['CCCCC'] - targets = [[5.0]] - ds_eval = QSPRDataset(smiles, targets, backend=_BACKEND) - res = tune_model_architecture(1, 1, ds_train, ds_eval, _N_PROCESSES,) - for k in list(res.keys()): - assert res[k] >= CONFIG['architecture_params_range'][k][0] - assert res[k] <= CONFIG['architecture_params_range'][k][1] - - -def test_tune_training_hyperparams(): - smiles = ['CCC', 'CCCC', 'CCCCCC'] - targets = [[3.0], [4.0], [6.0]] - ds_train = QSPRDataset(smiles, targets, backend=_BACKEND) - smiles = ['CCCCC'] - targets = [[5.0]] - ds_eval = QSPRDataset(smiles, targets, backend=_BACKEND) - res = tune_training_parameters(1, 1, ds_train, ds_eval, _N_PROCESSES) - for k in list(res.keys()): - assert res[k] >= CONFIG['training_params_range'][k][0] - assert res[k] <= CONFIG['training_params_range'][k][1] diff --git a/tests/test_api_signatures.py b/tests/test_api_signatures.py new file mode 100644 index 0000000..4d9d93f --- /dev/null +++ b/tests/test_api_signatures.py @@ -0,0 +1,377 @@ +"""Signature locks for the frozen public API (design §8.1–§8.2). + +Compares ``inspect.signature`` parameter names, kinds, and defaults. +Annotations are intentionally not locked (e.g. ``List[str]`` vs ``list[str]``). +""" + +from __future__ import annotations + +import inspect +from inspect import Parameter + +import pytest + +from ecnet import ECNet +from ecnet.blends import ( + cetane_number, + cloud_point, + exponential_blend_err, + kinematic_viscosity, + kv_error, + linear_blend_err, + lower_heating_value, + yield_sooting_index, +) +from ecnet.callbacks import Callback, CallbackOperator, LRDecayLinear, Validator +from ecnet.datasets import ( + QSPRDataset, + QSPRDatasetFromFile, + QSPRDatasetFromValues, + load_bp, + load_cn, + load_cp, + load_kv, + load_lhv, + load_mon, + load_mp, + load_pp, + load_ron, + load_ysi, +) +from ecnet.model import load_model +from ecnet.tasks import ( + select_rfr, + tune_batch_size, + tune_model_architecture, + tune_training_parameters, +) + +# (name, kind, default) — use inspect.Parameter.empty for required params +_EMPTY = Parameter.empty +_POSITIONAL_OR_KEYWORD = Parameter.POSITIONAL_OR_KEYWORD +_VAR_KEYWORD = Parameter.VAR_KEYWORD + + +def _assert_signature(fn, expected: list[tuple]) -> None: + """Assert parameter names, kinds, and defaults match ``expected``.""" + sig = inspect.signature(fn) + actual = [ + (name, param.kind, param.default) for name, param in sig.parameters.items() + ] + assert actual == expected, ( + f"Signature drift for {getattr(fn, '__qualname__', fn)}:\n" + f" expected={expected}\n" + f" actual ={actual}" + ) + + +def _pok(name: str, default=_EMPTY) -> tuple: + return (name, _POSITIONAL_OR_KEYWORD, default) + + +def _varkw(name: str = "kwargs") -> tuple: + return (name, _VAR_KEYWORD, _EMPTY) + + +_LOADERS = [ + load_bp, + load_cn, + load_cp, + load_kv, + load_lhv, + load_mon, + load_mp, + load_pp, + load_ron, + load_ysi, +] + +_BLEND_PREDICTORS = [ + cetane_number, + cloud_point, + kinematic_viscosity, + lower_heating_value, + yield_sooting_index, +] + + +def test_ecnet_init_signature() -> None: + _assert_signature( + ECNet.__init__, + [ + _pok("self"), + _pok("input_dim"), + _pok("output_dim"), + _pok("hidden_dim"), + _pok("n_hidden"), + _pok("dropout", 0.0), + _pok("device", "cpu"), + ], + ) + + +def test_ecnet_fit_signature() -> None: + _assert_signature( + ECNet.fit, + [ + _pok("self"), + _pok("smiles", None), + _pok("target_vals", None), + _pok("dataset", None), + _pok("backend", "padel"), + _pok("batch_size", 32), + _pok("epochs", 100), + _pok("lr_decay", 0.0), + _pok("valid_size", 0.0), + _pok("valid_eval_iter", 1), + _pok("patience", 16), + _pok("verbose", 0), + _pok("random_state", None), + _pok("shuffle", False), + _varkw(), + ], + ) + + +def test_ecnet_forward_signature() -> None: + _assert_signature( + ECNet.forward, + [ + _pok("self"), + _pok("x"), + ], + ) + + +def test_ecnet_save_signature() -> None: + _assert_signature( + ECNet.save, + [ + _pok("self"), + _pok("model_filename"), + ], + ) + + +def test_load_model_signature() -> None: + _assert_signature( + load_model, + [ + _pok("model_filename"), + ], + ) + + +@pytest.mark.parametrize("loader", _LOADERS, ids=[fn.__name__ for fn in _LOADERS]) +def test_load_prop_signature(loader) -> None: + _assert_signature( + loader, + [ + _pok("as_dataset", False), + _pok("backend", "padel"), + ], + ) + + +def test_qsprdataset_init_signature() -> None: + _assert_signature( + QSPRDataset.__init__, + [ + _pok("self"), + _pok("smiles"), + _pok("target_vals"), + _pok("backend", "padel"), + ], + ) + + +def test_qsprdataset_from_file_init_signature() -> None: + _assert_signature( + QSPRDatasetFromFile.__init__, + [ + _pok("self"), + _pok("smiles_fn"), + _pok("target_vals"), + _pok("backend", "padel"), + ], + ) + + +def test_qsprdataset_from_values_init_signature() -> None: + _assert_signature( + QSPRDatasetFromValues.__init__, + [ + _pok("self"), + _pok("desc_vals"), + _pok("target_vals"), + ], + ) + + +def test_select_rfr_signature() -> None: + _assert_signature( + select_rfr, + [ + _pok("dataset"), + _pok("total_importance", 0.95), + _varkw(), + ], + ) + + +@pytest.mark.parametrize( + "tune_fn", + [tune_batch_size, tune_model_architecture, tune_training_parameters], + ids=["tune_batch_size", "tune_model_architecture", "tune_training_parameters"], +) +def test_tune_helper_signatures(tune_fn) -> None: + _assert_signature( + tune_fn, + [ + _pok("n_bees"), + _pok("n_iter"), + _pok("dataset_train"), + _pok("dataset_eval"), + _pok("n_processes", 1), + _varkw(), + ], + ) + + +@pytest.mark.parametrize( + "blend_fn", + _BLEND_PREDICTORS, + ids=[fn.__name__ for fn in _BLEND_PREDICTORS], +) +def test_blend_predictor_signatures(blend_fn) -> None: + _assert_signature( + blend_fn, + [ + _pok("values"), + _pok("vol_fractions"), + ], + ) + + +def test_linear_blend_err_signature() -> None: + _assert_signature( + linear_blend_err, + [ + _pok("errors"), + _pok("proportions"), + ], + ) + + +def test_exponential_blend_err_signature() -> None: + _assert_signature( + exponential_blend_err, + [ + _pok("values"), + _pok("result"), + _pok("errors"), + _pok("proportions"), + _pok("a"), + _pok("b"), + ], + ) + + +def test_kv_error_signature() -> None: + _assert_signature( + kv_error, + [ + _pok("values"), + _pok("errors"), + _pok("proportions"), + ], + ) + + +def test_callback_init_signature() -> None: + _assert_signature(Callback.__init__, [_pok("self")]) + + +def test_callback_operator_init_signature() -> None: + _assert_signature(CallbackOperator.__init__, [_pok("self")]) + + +def test_lr_decay_linear_init_signature() -> None: + _assert_signature( + LRDecayLinear.__init__, + [ + _pok("self"), + _pok("init_lr"), + _pok("decay_rate"), + _pok("optimizer"), + ], + ) + + +def test_validator_init_signature() -> None: + _assert_signature( + Validator.__init__, + [ + _pok("self"), + _pok("loader"), + _pok("model"), + _pok("eval_iter"), + _pok("patience"), + ], + ) + + +def test_ecnet_getattr_unknown_attribute() -> None: + import ecnet + + with pytest.raises(AttributeError, match="has no attribute"): + _ = ecnet.not_a_public_symbol + + +def test_public_surface_importable() -> None: + """Sanity: every §8.1 symbol remains importable under its public path.""" + from ecnet import __version__ + from ecnet.blends import ( # noqa: F401 + cetane_number, + cloud_point, + exponential_blend_err, + kinematic_viscosity, + kv_error, + linear_blend_err, + lower_heating_value, + yield_sooting_index, + ) + from ecnet.callbacks import ( # noqa: F401 + Callback, + CallbackOperator, + LRDecayLinear, + Validator, + ) + from ecnet.datasets import ( # noqa: F401 — re-check package exports + QSPRDataset, + QSPRDatasetFromFile, + QSPRDatasetFromValues, + load_bp, + load_cn, + load_cp, + load_kv, + load_lhv, + load_mon, + load_mp, + load_pp, + load_ron, + load_ysi, + ) + from ecnet.tasks import ( # noqa: F401 + select_rfr, + tune_batch_size, + tune_model_architecture, + tune_training_parameters, + ) + + assert isinstance(__version__, str) + assert __version__ + # PCADataset must remain an advanced import, not a datasets package export. + import ecnet.datasets as datasets_pkg + + assert not hasattr(datasets_pkg, "PCADataset") diff --git a/tests/test_cwd_hygiene.py b/tests/test_cwd_hygiene.py new file mode 100644 index 0000000..c21ae1d --- /dev/null +++ b/tests/test_cwd_hygiene.py @@ -0,0 +1,49 @@ +"""Regression: library save/load must not pollute the process CWD.""" + +from __future__ import annotations + +from pathlib import Path + +import torch + +from ecnet import ECNet +from ecnet.datasets.structs import QSPRDatasetFromValues +from ecnet.model import load_model + +_SEED = 0 + + +def test_save_load_does_not_pollute_cwd(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + + torch.manual_seed(_SEED) + ds = QSPRDatasetFromValues( + [ + [0.0, 0.1, 0.2, 0.3], + [0.1, 0.2, 0.3, 0.4], + [0.2, 0.3, 0.4, 0.5], + [0.3, 0.4, 0.5, 0.6], + ], + [[1.0], [2.0], [3.0], [4.0]], + ) + net = ECNet(4, 1, 8, 1) + net.fit( + dataset=ds, + epochs=2, + batch_size=2, + random_state=_SEED, + shuffle=False, + ) + net.save("model.pt") + loaded = load_model("model.pt") + assert isinstance(loaded, ECNet) + + names = sorted(p.name for p in tmp_path.iterdir()) + assert names == ["model.pt"] + banned = ( + "_temp.smiles", + "_temp.target", + "_test.pt", + ) + for name in banned: + assert not (tmp_path / name).exists()