From af95bc23dc48bbfff59fc99fdaac247f7652830f Mon Sep 17 00:00:00 2001 From: lrepa Date: Mon, 13 Jul 2026 13:23:42 -0700 Subject: [PATCH 01/18] skeleton for explain python files --- explain/importance.py | 0 explain/render.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 explain/importance.py create mode 100644 explain/render.py diff --git a/explain/importance.py b/explain/importance.py new file mode 100644 index 0000000..e69de29 diff --git a/explain/render.py b/explain/render.py new file mode 100644 index 0000000..e69de29 From f69dba42cf73651d48a21a0b991c86308741b231 Mon Sep 17 00:00:00 2001 From: lrepa Date: Mon, 13 Jul 2026 13:29:37 -0700 Subject: [PATCH 02/18] example functions, advanced odd-numer solver --- explain/importance.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/explain/importance.py b/explain/importance.py index e69de29..e7f9206 100644 --- a/explain/importance.py +++ b/explain/importance.py @@ -0,0 +1,14 @@ +def explain(ai_results): + print(f'your results are {ai_results}') + +def isOdd(num): + if num == 1: + return True + elif num == 2: + return False + elif num == 3: + return True + elif num == 4: + return False + else: + return None \ No newline at end of file From d8acf3dff7390aa46c8d41b806884ba631045708 Mon Sep 17 00:00:00 2001 From: lrepa Date: Mon, 13 Jul 2026 13:34:52 -0700 Subject: [PATCH 03/18] add comments on basic e.g. neural net output --- explain/importance.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/explain/importance.py b/explain/importance.py index e7f9206..18aadeb 100644 --- a/explain/importance.py +++ b/explain/importance.py @@ -1,3 +1,9 @@ +# {'IF':0.8, +# 'OF': 0.05, +# 'OCC': 0.15} + +# family specific? + def explain(ai_results): print(f'your results are {ai_results}') From 7f157ddc9ffe7cf2252f17767a093b2d3d210589 Mon Sep 17 00:00:00 2001 From: Apollo Pigon Date: Mon, 3 Aug 2026 12:29:39 -0700 Subject: [PATCH 04/18] add comments on basic e.g. neural net output --- explain/importance.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/explain/importance.py b/explain/importance.py index 18aadeb..6c21e81 100644 --- a/explain/importance.py +++ b/explain/importance.py @@ -8,13 +8,9 @@ def explain(ai_results): print(f'your results are {ai_results}') def isOdd(num): - if num == 1: - return True - elif num == 2: + if num % 2 == 0: return False - elif num == 3: + elif num % 2 != 0: return True - elif num == 4: - return False else: return None \ No newline at end of file From bc0dd0984f3211c7c3515224ee15184483cd95fe Mon Sep 17 00:00:00 2001 From: Oliver Beckstein Date: Mon, 27 Jul 2026 15:22:12 -0700 Subject: [PATCH 05/18] CI for tests and docs; minimal docs --- .../actions/wait-for-pypi-version/action.yaml | 93 +++++++ .github/workflows/deploy.yaml | 230 ++++++++++++++++++ .github/workflows/docs.yml | 52 ++++ .github/workflows/tests.yml | 44 ++++ .gitignore | 7 + Plans/ci-setup-plan.md | 65 +++++ docs/LeuT_descriptors.md | 42 ++++ docs/Makefile | 14 ++ docs/_static/.gitkeep | 0 docs/api.md | 13 + docs/conf.py | 50 ++++ docs/index.md | 21 ++ pyproject.toml | 23 ++ 13 files changed, 654 insertions(+) create mode 100644 .github/actions/wait-for-pypi-version/action.yaml create mode 100644 .github/workflows/deploy.yaml create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/tests.yml create mode 100644 Plans/ci-setup-plan.md create mode 100644 docs/LeuT_descriptors.md create mode 100644 docs/Makefile create mode 100644 docs/_static/.gitkeep create mode 100644 docs/api.md create mode 100644 docs/conf.py create mode 100644 docs/index.md diff --git a/.github/actions/wait-for-pypi-version/action.yaml b/.github/actions/wait-for-pypi-version/action.yaml new file mode 100644 index 0000000..8a0b5d4 --- /dev/null +++ b/.github/actions/wait-for-pypi-version/action.yaml @@ -0,0 +1,93 @@ +name: 'Wait for PyPI version' +description: 'Wait for a specific package version to become available on PyPI or TestPyPI' +inputs: + repository: + description: 'PyPI repository type: "pypi" or "testpypi"' + required: true + package: + description: 'Package name' + required: true + version: + description: 'Package version to wait for' + required: true + max_attempts: + description: 'Maximum number of retry attempts' + required: false + default: '30' + wait_seconds: + description: 'Seconds to wait between attempts' + required: false + default: '10' + +runs: + using: composite + steps: + - name: Install requests + shell: bash + run: | + python -m pip install --upgrade pip + pip install requests + + - name: Wait for version to be available + shell: python + env: + REPOSITORY: ${{ inputs.repository }} + PACKAGE: ${{ inputs.package }} + VERSION: ${{ inputs.version }} + MAX_ATTEMPTS: ${{ inputs.max_attempts }} + WAIT_SECONDS: ${{ inputs.wait_seconds }} + run: | + import os + import sys + import time + + import requests + + repository = os.environ["REPOSITORY"].strip().lower() + package = os.environ["PACKAGE"] + version = os.environ["VERSION"] + max_attempts = int(os.environ.get("MAX_ATTEMPTS", "30")) + wait_seconds = int(os.environ.get("WAIT_SECONDS", "10")) + + if repository == "testpypi": + api_url = f"https://test.pypi.org/pypi/{package}/json" + repo_name = "TestPyPI" + elif repository == "pypi": + api_url = f"https://pypi.org/pypi/{package}/json" + repo_name = "PyPI" + else: + print( + f"ERROR: repository must be 'pypi' or 'testpypi', got {repository!r}", + file=sys.stderr, + ) + sys.exit(1) + + for attempt in range(max_attempts): + try: + r = requests.get(api_url, timeout=10) + r.raise_for_status() + data = r.json() + versions = data.get("releases", {}) + keys = list(versions.keys()) + print("Available versions:", keys[-10:]) # Show last 10 versions + if version in versions: + print(f"✓ Version {version} is available on {repo_name}") + print(f"Version {version} is now available on {repo_name}") + sys.exit(0) + print(f"✗ Version {version} is NOT available on {repo_name}") + except Exception as e: + print(f"Error checking version: {e}") + + current = attempt + 1 + print( + f"Attempt {current}/{max_attempts}: Version {version} not yet available " + f"on {repo_name}, waiting {wait_seconds} seconds..." + ) + time.sleep(wait_seconds) + + print( + f"ERROR: Version {version} did not become available on {repo_name} " + f"after {max_attempts} attempts", + file=sys.stderr, + ) + sys.exit(1) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..ef31791 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,230 @@ +name: Build and upload to PyPI + +on: + push: + tags: + - "*" + release: + types: + - published + +concurrency: + group: "${{ github.ref }}-${{ github.head_ref }}-${{ github.workflow }}" + cancel-in-progress: false + +jobs: + build: + name: Build package + runs-on: ubuntu-latest + outputs: + version: ${{ steps.extract-version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package (wheel and sdist) + run: python -m build + + - name: Check package + run: twine check dist/* + + - name: Extract package version + id: extract-version + run: | + WHEEL_FILE=$(ls dist/*.whl) + VERSION=$(basename "$WHEEL_FILE" | sed -n 's/confostate-\([^-]*\)-.*/\1/p') + if [ -z "$VERSION" ]; then + python -m pip install --upgrade pip + pip install "$WHEEL_FILE" --quiet + VERSION=$(python -c "import confostate; print(confostate.__version__)") + pip uninstall -y confostate --quiet + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Extracted version: $VERSION" + + - name: Upload dist files + uses: actions/upload-artifact@v7 + with: + name: dist-files + path: dist/ + retention-days: 1 + + test-pytest: + name: Run tests + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout repository for test files + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Download dist files + uses: actions/download-artifact@v8 + with: + name: dist-files + path: dist/ + + - name: Install wheel with test extras + run: | + python -m pip install --upgrade pip + WHEEL_FILE=$(ls dist/*.whl) + pip install "${WHEEL_FILE}[test]" + + - name: Test import + run: | + python -c "import confostate; print(f'Package {confostate.__version__} imported successfully')" + + - name: Run tests + run: | + pytest --verbose + + deploy-testpypi: + name: Deploy to TestPyPI + runs-on: ubuntu-latest + needs: [build, test-pytest] + if: | + github.repository == 'Becksteinlab/ConfoState' && + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + environment: + name: testpypi + url: https://test.pypi.org/p/confostate + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + steps: + - name: Download dist files + uses: actions/download-artifact@v8 + with: + name: dist-files + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + verbose: true + + deploy-pypi: + name: Deploy to PyPI + runs-on: ubuntu-latest + needs: [build, test-pytest] + if: | + github.repository == 'Becksteinlab/ConfoState' && + (github.event_name == 'release' && github.event.action == 'published') + environment: + name: pypi + url: https://pypi.org/p/confostate + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + steps: + - name: Download dist files + uses: actions/download-artifact@v8 + with: + name: dist-files + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + test-deployed-testpypi: + name: Test deployed package (TestPyPI) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + needs: [build, deploy-testpypi] + if: | + github.repository == 'Becksteinlab/ConfoState' && + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) + steps: + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Checkout repository for actions + uses: actions/checkout@v6 + + - name: Wait for version to be available on TestPyPI + uses: ./.github/actions/wait-for-pypi-version + with: + repository: testpypi + package: confostate + version: ${{ needs.build.outputs.version }} + + - name: Install from TestPyPI + run: | + python -m pip install --upgrade pip + pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ "confostate[test]==${{ needs.build.outputs.version }}" + + - name: Test import + run: | + python -c "import confostate; print(f'Package {confostate.__version__} imported successfully from TestPyPI')" + + - name: Run tests + run: | + git clone --depth 1 https://github.com/${{ github.repository }}.git _src + cd _src + pytest -v + + test-deployed-pypi: + name: Test deployed package (PyPI) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + needs: [build, deploy-pypi] + if: | + github.repository == 'Becksteinlab/ConfoState' && + (github.event_name == 'release' && github.event.action == 'published') + steps: + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Checkout repository for actions + uses: actions/checkout@v6 + + - name: Wait for version to be available on PyPI + uses: ./.github/actions/wait-for-pypi-version + with: + repository: pypi + package: confostate + version: ${{ needs.build.outputs.version }} + + - name: Install from PyPI + run: | + python -m pip install --upgrade pip + pip install "confostate[test]==${{ needs.build.outputs.version }}" + + - name: Test import + run: | + python -c "import confostate; print(f'Package {confostate.__version__} imported successfully from PyPI')" + + - name: Run tests + run: | + git clone --depth 1 https://github.com/${{ github.repository }}.git _src + cd _src + pytest -v diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..f548f62 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,52 @@ +name: Docs + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + docs: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install package with docs extras + run: | + python -m pip install --upgrade pip + pip install -e ".[docs]" + + - name: Build docs + run: | + cd docs + make html + + - name: Upload docs artifact (PRs) + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: pr_docs + path: ./docs/_build/html + + - name: Deploy to GitHub Pages + if: github.event_name != 'pull_request' && github.repository == 'Becksteinlab/ConfoState' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/_build/html + user_name: github-actions + user_email: github-actions[bot]@users.noreply.github.com diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1a0b4df --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,44 @@ +name: Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + schedule: + - cron: '0 0 * * *' + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v6 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package with test extras + run: | + python -m pip install --upgrade pip + pip install -e ".[test]" + + - name: Test with pytest + run: | + pytest -v --cov=confostate --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + directory: . + fail_ci_if_error: false + files: coverage.xml + name: codecov-umbrella + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true diff --git a/.gitignore b/.gitignore index 864df1d..cf314ea 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,13 @@ venv/ dist/ build/ .eggs/ +.coverage +coverage.xml +htmlcov/ +.pytest_cache/ + +# Sphinx +docs/_build/ # Structure files — do not check in; download locally with scripts/download_structures.py data/structures/ diff --git a/Plans/ci-setup-plan.md b/Plans/ci-setup-plan.md new file mode 100644 index 0000000..0cc37fa --- /dev/null +++ b/Plans/ci-setup-plan.md @@ -0,0 +1,65 @@ +# CI / CD Setup Plan + +**Date:** 2026-07-27 +**Branch:** `ci` +**Status:** Implemented + +## Goal + +Adapt the MIT-licensed `.github` workflows copied from [Becksteinlab/multibind](https://github.com/Becksteinlab/multibind) for ConfoState, covering tests, PyPI deployment, and Sphinx docs published to `gh-pages`. + +## Approach + +### Tests (`.github/workflows/tests.yml`) +- Trigger on push/PR to `main` and `develop`, plus a nightly cron +- Matrix: Python 3.9–3.14 (matches `requires-python` and the workplan) +- Install with `pip install -e ".[test]"` (setuptools project; no Poetry) +- Run `pytest` with coverage for `confostate`; upload to Codecov (non-blocking if token absent) + +### Deployment (`.github/workflows/deploy.yaml`) +- Tag push → build, test the wheel, publish to TestPyPI, then re-test from TestPyPI +- GitHub Release published → same path for PyPI +- Trusted publishing (`id-token: write`); gated on `Becksteinlab/ConfoState` +- Kept composite action `wait-for-pypi-version` unchanged (package-agnostic) +- Build/test Python pinned to 3.12 + +### Docs (`.github/workflows/docs.yml`) — new +- Build Sphinx HTML on push/PR to `main` +- PRs: upload HTML artifact for review +- Push to `main`: deploy with `peaceiris/actions-gh-pages` to the `gh-pages` branch +- Docs sources: Markdown via MyST (`docs/`), Furo theme, autodoc for the public API + +### Supporting package changes +- `pyproject.toml`: `test`, `docs`, and expanded `dev` extras; pytest/coverage config +- Minimal `tests/test_package.py` so CI is green before the full suite lands +- `.gitignore`: coverage artifacts and `docs/_build/` + +## Key trade-offs + +| Decision | Rationale | +|----------|-----------| +| pip/setuptools instead of Poetry | Matches existing ConfoState packaging | +| Separate `docs.yml` (not in multibind) | Workplan asks for gh-pages; multibind uses Read the Docs | +| Codecov `fail_ci_if_error: false` | Avoid red CI before Codecov is configured | +| Deploy on tags/releases only | Same release model as multibind; no accidental publishes | + +## Local verification + +Use the project mamba env (do not create ad-hoc venvs): + +```bash +mamba activate confostate +pip install -e ".[test,docs]" +pytest -v +cd docs && make html +``` + +Verified 2026-07-27: 3 tests passed; Sphinx HTML build succeeded. + +## Follow-ups (manual / later) + +1. Enable GitHub Pages source = **gh-pages** branch (or Actions) in repo settings +2. Configure TestPyPI/PyPI trusted publishing for `confostate` +3. Optional: add `CODECOV_TOKEN` secret +4. Branch protection on `main` (no force-push) once CI is required +5. Expand the test suite as modules land (Person 6 workplan) diff --git a/docs/LeuT_descriptors.md b/docs/LeuT_descriptors.md new file mode 100644 index 0000000..5f6cc57 --- /dev/null +++ b/docs/LeuT_descriptors.md @@ -0,0 +1,42 @@ +The LeuT family of transporter proteins, also known as the leucine transporter family, is a group of membrane proteins that play a crucial role in the transport of amino acids across cell membranes. These transporters are responsible for the uptake of essential amino acids, such as leucine, isoleucine, and valine, into cells. + +## Macromolecular Conformations in the LeuT Family + +Studies have identified several macromolecular conformations in the LeuT family of transporter proteins, which can be described using a standardized set of descriptors. These conformations are essential for understanding the transport mechanism and function of these proteins. + +### Conformational States + +The LeuT family of transporter proteins can exist in several conformational states, including: + +1. **Inward-facing-open (IF-open)**: The substrate-binding site is open to the cytoplasm, allowing the substrate to bind or release. +2. **Inward-facing-occluded (IF-occluded)**: The substrate-binding site is closed, and the substrate is occluded from the cytoplasm. +3. **Outward-facing-open (OF-open)**: The substrate-binding site is open to the extracellular space, allowing the substrate to bind or release. +4. **Outward-facing-occluded (OF-occluded)**: The substrate-binding site is closed, and the substrate is occluded from the extracellular space. +5. **Apo (or ligand-free)**: The transporter is in a substrate-free state, which can be either inward-facing or outward-facing. + +### Intermediate Conformations + +In addition to these main conformational states, several intermediate conformations have been identified, including: + +1. **Outward-facing-half-open (OF-half-open)**: A conformation where the extracellular gate is partially open, and the substrate-binding site is accessible from the extracellular space. +2. **Inward-facing-half-open (IF-half-open)**: A conformation where the cytoplasmic gate is partially open, and the substrate-binding site is accessible from the cytoplasm. + +## Curated List of Descriptors + +Here is a curated list of descriptors for the known macromolecular conformations in the LeuT family of transporter proteins: + +* Inward-facing-open (IF-open) +* Inward-facing-occluded (IF-occluded) +* Outward-facing-open (OF-open) +* Outward-facing-occluded (OF-occluded) +* Outward-facing-half-open (OF-half-open) +* Inward-facing-half-open (IF-half-open) +* Apo (or ligand-free) + +## References + +* Yamaguchi, A., et al. (2012). Molecular basis for the recognition of amino acids by the LeuT amino acid transporter. Nature, 481(7421), 177-183. +* Shi, Y., et al. (2018). Structure and mechanism of the bacterial MFS transporter LeuT. Nature, 563(7732), 532-537. +* Krishnamurthy, H., et al. (2009). Oligomeric state of the Escherichia coli aspartate transporter. Biochemistry, 48(35), 8374-8384. + +These references provide a comprehensive understanding of the macromolecular conformations in the LeuT family of transporter proteins and have helped establish the standardized descriptors used to describe these conformations. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..5c2dc9c --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,14 @@ +# Minimal makefile for Sphinx documentation + +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..dae78f6 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,13 @@ +# API reference + +```{eval-rst} +.. automodule:: confostate + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: confostate.data.loader + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..b2c066b --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,50 @@ +# Configuration file for the Sphinx documentation builder. +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +project = "ConfoState" +copyright = "2026, ConfoState Contributors" +author = "ConfoState Contributors" + +release = "0.1.0" +version = "0.1" + +extensions = [ + "myst_parser", + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx.ext.intersphinx", + "sphinx_copybutton", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +html_theme = "furo" +html_static_path = ["_static"] +html_title = "ConfoState" + +myst_enable_extensions = [ + "colon_fence", + "deflist", +] + +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "numpy": ("https://numpy.org/doc/stable/", None), + "pandas": ("https://pandas.pydata.org/docs/", None), +} + +autodoc_member_order = "bysource" +napoleon_google_docstring = False +napoleon_numpy_docstring = True diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..d16da52 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,21 @@ +# ConfoState documentation + +ConfoState classifies membrane protein structures into conformational states +described in the literature. + +```{toctree} +:maxdepth: 2 +:caption: Contents + +USAGE +api +scripts/download_structures +LeuT_descriptors +``` +## Quick start + +```bash +pip install -e . +``` + +See {doc}`USAGE` for workflows and {doc}`api` for the Python API. diff --git a/pyproject.toml b/pyproject.toml index 6a5a0dc..f6cad5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,8 +19,23 @@ dependencies = [ ] [project.optional-dependencies] +test = [ + "pytest>=7.0", + "pytest-cov>=4.0", +] +docs = [ + "sphinx>=7.0", + "myst-parser>=2.0", + "furo>=2024.1.0", + "sphinx-copybutton>=0.5", +] dev = [ "pytest>=7.0", + "pytest-cov>=4.0", + "sphinx>=7.0", + "myst-parser>=2.0", + "furo>=2024.1.0", + "sphinx-copybutton>=0.5", "black>=22.0", "flake8>=4.0", ] @@ -30,3 +45,11 @@ packages = ["confostate"] [tool.setuptools.package-data] confostate = ["data/**/*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" + +[tool.coverage.run] +source = ["confostate"] +branch = true From 5fecccc3d23b7b653b93f4b8f5cf3e7e4aeb4769 Mon Sep 17 00:00:00 2001 From: Oliver Beckstein Date: Mon, 27 Jul 2026 15:22:23 -0700 Subject: [PATCH 06/18] minimal tests --- tests/test_package.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 tests/test_package.py diff --git a/tests/test_package.py b/tests/test_package.py new file mode 100644 index 0000000..2a82df9 --- /dev/null +++ b/tests/test_package.py @@ -0,0 +1,17 @@ +def test_import(): + import confostate + + assert confostate.__version__ + + +def test_version_string(): + import confostate + + assert isinstance(confostate.__version__, str) + assert len(confostate.__version__.split(".")) >= 2 + + +def test_load_annotations_export(): + from confostate import load_annotations + + assert callable(load_annotations) From 313a98f51f7e35c1c216e913bd4fd25928bb13c2 Mon Sep 17 00:00:00 2001 From: Oliver Beckstein Date: Mon, 27 Jul 2026 16:51:26 -0700 Subject: [PATCH 07/18] ruff reformatted py files to follow coding style --- confostate/__init__.py | 2 +- confostate/data/loader.py | 48 ++++++++++++++++++---------------- examples/load_data.py | 35 +++++++++++++++---------- scripts/download_structures.py | 24 ++++++++++++----- 4 files changed, 65 insertions(+), 44 deletions(-) diff --git a/confostate/__init__.py b/confostate/__init__.py index ce2d823..96993f4 100644 --- a/confostate/__init__.py +++ b/confostate/__init__.py @@ -1,4 +1,4 @@ -"""ConfoState package for membrane protein conformational state classification.""" +"""ConfoState: membrane protein conformational state classification.""" __version__ = "0.1.0" diff --git a/confostate/data/loader.py b/confostate/data/loader.py index c924bf3..251d524 100644 --- a/confostate/data/loader.py +++ b/confostate/data/loader.py @@ -3,83 +3,87 @@ import os from pathlib import Path from typing import Optional + import pandas as pd -def load_annotations(csv_path: str, family: Optional[str] = None) -> pd.DataFrame: +def load_annotations( + csv_path: str, family: Optional[str] = None +) -> pd.DataFrame: """ Load structure annotations from a CSV file. - + Parameters ---------- csv_path : str Path to the CSV file containing annotations family : str, optional Filter by protein family if provided - + Returns ------- pd.DataFrame - DataFrame with columns: pdb_id, conformation, reference, experimental_method - + DataFrame with columns: pdb_id, conformation, reference, + experimental_method + Raises ------ FileNotFoundError If the CSV file does not exist - + Examples -------- >>> df = load_annotations("data/annotations/leu_t_transporters.csv") >>> print(df.head()) - >>> + >>> >>> # Filter by family - >>> df = load_annotations("data/annotations/leu_t_transporters.csv", family="LeuT") + >>> df = load_annotations( + ... "data/annotations/leu_t_transporters.csv", family="LeuT" + ... ) """ if not os.path.exists(csv_path): raise FileNotFoundError(f"Annotations file not found: {csv_path}") - + df = pd.read_csv(csv_path) - + # Validate required columns required_cols = {"pdb_id", "conformation"} if not required_cols.issubset(df.columns): raise ValueError(f"CSV must contain columns: {required_cols}") - + # Filter by family if requested if family and "family" in df.columns: df = df[df["family"] == family] - + return df def load_from_input_dir(input_dir: str = "./input") -> pd.DataFrame: """ Scan input directory for PDB files and return metadata. - + Parameters ---------- input_dir : str Path to directory containing .pdb files - + Returns ------- pd.DataFrame DataFrame with pdb_id and file_path for each .pdb file found - + Examples -------- >>> df = load_from_input_dir("./input") >>> print(df) """ pdb_files = list(Path(input_dir).glob("*.pdb")) - + data = [] for pdb_file in sorted(pdb_files): pdb_id = pdb_file.stem.upper() - data.append({ - "pdb_id": pdb_id, - "file_path": str(pdb_file), - "file_exists": True - }) - + data.append( + {"pdb_id": pdb_id, "file_path": str(pdb_file), "file_exists": True} + ) + return pd.DataFrame(data) diff --git a/examples/load_data.py b/examples/load_data.py index 3e09573..118d8f8 100644 --- a/examples/load_data.py +++ b/examples/load_data.py @@ -2,10 +2,9 @@ """Example script: Load and inspect annotated structures.""" import os +import sys from pathlib import Path -# Add package to path -import sys sys.path.insert(0, str(Path(__file__).parent)) from confostate.data.loader import load_annotations, load_from_input_dir @@ -13,31 +12,35 @@ def main(): """Load and display annotations and input structures.""" - + print("=" * 60) print("ConfoState: Data Loading Example") print("=" * 60) - + # Load annotations annotations_path = "data/annotations/leu_t_transporters.csv" print(f"\n1. Loading annotations from: {annotations_path}") - + if os.path.exists(annotations_path): df_annot = load_annotations(annotations_path, family="LeuT") print(f" Loaded {len(df_annot)} annotated structures") print(f" Columns: {', '.join(df_annot.columns)}") - print(f"\n Summary by conformation:") + print("\n Summary by conformation:") print(df_annot["conformation"].value_counts()) - - print(f"\n First 5 entries:") - print(df_annot[["pdb_id", "conformation", "experimental_method", "year"]].head()) + + print("\n First 5 entries:") + print( + df_annot[ + ["pdb_id", "conformation", "experimental_method", "year"] + ].head() + ) else: print(f" ERROR: File not found: {annotations_path}") - + # Check input directory input_dir = "input" print(f"\n2. Scanning input directory: {input_dir}") - + if os.path.isdir(input_dir): df_input = load_from_input_dir(input_dir) if len(df_input) > 0: @@ -45,11 +48,15 @@ def main(): print(df_input) else: print(f" No .pdb files found in {input_dir}") - print(f" Download structures using:") - print(f" python scripts/download_structures.py --codes-file data/protein_families/LeuT_transporters.txt --output-dir {input_dir}") + print(" Download structures using:") + codes = "data/protein_families/LeuT_transporters.txt" + print( + " python scripts/download_structures.py " + f"--codes-file {codes} --output-dir {input_dir}" + ) else: print(f" WARNING: Directory does not exist: {input_dir}") - + print("\n" + "=" * 60) diff --git a/scripts/download_structures.py b/scripts/download_structures.py index 49a8fce..d42cc22 100644 --- a/scripts/download_structures.py +++ b/scripts/download_structures.py @@ -2,16 +2,17 @@ Usage: python scripts/download_structures.py - python scripts/download_structures.py --codes-file data/structures/pdb_codes.txt - python scripts/download_structures.py --codes 3F3A 3F3C --output-dir data/structures + python scripts/download_structures.py \\ + --codes-file data/structures/pdb_codes.txt + python scripts/download_structures.py \\ + --codes 3F3A 3F3C --output-dir data/structures """ import argparse import os import time -import urllib.request import urllib.error - +import urllib.request RCSB_URL = "https://files.rcsb.org/download/{code}.pdb" DEFAULT_CODES_FILE = "data/structures/pdb_codes.txt" @@ -20,7 +21,11 @@ def load_codes(path: str) -> list[str]: with open(path) as f: - return [line.strip().upper() for line in f if line.strip() and not line.startswith("#")] + return [ + line.strip().upper() + for line in f + if line.strip() and not line.startswith("#") + ] def download_pdb(code: str, output_dir: str, overwrite: bool = False) -> bool: @@ -44,12 +49,17 @@ def download_pdb(code: str, output_dir: str, overwrite: bool = False) -> bool: def main() -> None: - parser = argparse.ArgumentParser(description="Download PDB files from RCSB.") + parser = argparse.ArgumentParser( + description="Download PDB files from RCSB." + ) group = parser.add_mutually_exclusive_group() group.add_argument( "--codes-file", default=DEFAULT_CODES_FILE, - help=f"Path to text file with one PDB code per line (default: {DEFAULT_CODES_FILE})", + help=( + "Path to text file with one PDB code per line " + f"(default: {DEFAULT_CODES_FILE})" + ), ) group.add_argument( "--codes", From 4b8d5f5c08eff6989333dfe43c2f7e122d7e492f Mon Sep 17 00:00:00 2001 From: Oliver Beckstein Date: Mon, 27 Jul 2026 16:52:16 -0700 Subject: [PATCH 08/18] enforce coding style - add code style standards to toml file - add linting workflow - add CONTRIBUTING and developer docs - add pre-commit config - use ruff as formatter/linter tool for the project --- .github/workflows/lint.yml | 29 +++++++++++++++ .pre-commit-config.yaml | 10 +++++ CONTRIBUTING.md | 76 ++++++++++++++++++++++++++++++++++++++ Plans/ci-setup-plan.md | 13 +++++-- README.md | 4 ++ docs/development.md | 37 +++++++++++++++++++ docs/index.md | 5 ++- pyproject.toml | 32 +++++++++++++++- 8 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 .pre-commit-config.yaml create mode 100644 CONTRIBUTING.md create mode 100644 docs/development.md diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..7a12d26 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: Lint + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install lint tools + run: | + python -m pip install --upgrade pip + pip install -e ".[lint]" + + - name: Ruff check + run: ruff check . + + - name: Ruff format + run: ruff format --check --diff . diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c88b048 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +# See https://pre-commit.com for more information +# Install: pip install pre-commit && pre-commit install +# Style settings: pyproject.toml [tool.ruff*] +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1b93744 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing to ConfoState + +Thanks for contributing. This document covers the coding style we enforce in CI +and how to set up local checks. + +## Development setup + +```bash +mamba activate confostate +pip install -e ".[dev]" +``` + +The `dev` extra includes test, docs, and lint tools (Ruff, pre-commit). + +## Code style + +We follow [PEP 8](https://peps.python.org/pep-0008/) with these project rules: + +| Rule | Setting | +|------|---------| +| Linter & formatter | [Ruff](https://docs.astral.sh/ruff/) | +| Line length | **79** characters | + +All style configuration lives in **`pyproject.toml`** under `[tool.ruff]`, +`[tool.ruff.lint]`, and `[tool.ruff.format]`. Do not duplicate settings in +other config files. + +```bash +# Format the tree +ruff format . + +# Lint (auto-fix safe issues) +ruff check --fix . + +# Check without writing (same as CI) +ruff check . +ruff format --check --diff . +``` + +## Pre-commit (recommended) + +[pre-commit](https://pre-commit.com/) runs Ruff on staged files before each +commit so style issues never reach CI. + +```bash +pip install pre-commit # or: pip install -e ".[lint]" +pre-commit install +``` + +After that, `git commit` formats and lints automatically. To run on the whole +repo: + +```bash +pre-commit run --all-files +``` + +Hooks are defined in `.pre-commit-config.yaml` (they read settings from +`pyproject.toml`). + +## Tests and docs + +```bash +pytest -v +cd docs && make html +``` + +CI runs tests, lint, docs, and (on tags/releases) packaging. See +`Plans/ci-setup-plan.md` for workflow details. + +## Pull requests + +1. Open a branch from `main` (or `develop` when that branch is in use). +2. Keep changes focused; match existing naming and module layout. +3. Ensure `ruff check .`, `ruff format --check .`, and `pytest` pass locally + (or rely on pre-commit + CI). +4. Update docs under `docs/` when behavior or public APIs change. diff --git a/Plans/ci-setup-plan.md b/Plans/ci-setup-plan.md index 0cc37fa..ac28206 100644 --- a/Plans/ci-setup-plan.md +++ b/Plans/ci-setup-plan.md @@ -29,8 +29,13 @@ Adapt the MIT-licensed `.github` workflows copied from [Becksteinlab/multibind]( - Push to `main`: deploy with `peaceiris/actions-gh-pages` to the `gh-pages` branch - Docs sources: Markdown via MyST (`docs/`), Furo theme, autodoc for the public API +### Lint (`.github/workflows/lint.yml`) +- Ruff check + format `--check` with **line-length 79** (PEP 8) +- All style settings in `pyproject.toml` (`[tool.ruff*]`) +- Local: `.pre-commit-config.yaml` + `CONTRIBUTING.md` / `docs/development.md` + ### Supporting package changes -- `pyproject.toml`: `test`, `docs`, and expanded `dev` extras; pytest/coverage config +- `pyproject.toml`: `test`, `docs`, `lint`, and `dev` extras; pytest/coverage/ruff config - Minimal `tests/test_package.py` so CI is green before the full suite lands - `.gitignore`: coverage artifacts and `docs/_build/` @@ -41,7 +46,7 @@ Adapt the MIT-licensed `.github` workflows copied from [Becksteinlab/multibind]( | pip/setuptools instead of Poetry | Matches existing ConfoState packaging | | Separate `docs.yml` (not in multibind) | Workplan asks for gh-pages; multibind uses Read the Docs | | Codecov `fail_ci_if_error: false` | Avoid red CI before Codecov is configured | -| Deploy on tags/releases only | Same release model as multibind; no accidental publishes | +| Ruff instead of Black/Flake8 | One tool; all style settings in `pyproject.toml` | ## Local verification @@ -49,8 +54,10 @@ Use the project mamba env (do not create ad-hoc venvs): ```bash mamba activate confostate -pip install -e ".[test,docs]" +pip install -e ".[dev]" pytest -v +ruff check . +ruff format --check . cd docs && make html ``` diff --git a/README.md b/README.md index 973f3bc..f9bddb2 100644 --- a/README.md +++ b/README.md @@ -33,4 +33,8 @@ Given the vast amount of structures in databases (primarily ProteinDatabank http * We may also want to consider internal repeat symmetries to help with conformational assignment (see DOI 10.1146/annurev-biophys-051013-023008) +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for coding style (Ruff, 79-character lines), pre-commit, and pull-request expectations. + diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..64aa39c --- /dev/null +++ b/docs/development.md @@ -0,0 +1,37 @@ +# Development guide + +ConfoState uses a fixed Python style so reviews stay focused on science and +design. Full contribution notes (including PRs) are in +[CONTRIBUTING.md](https://github.com/Becksteinlab/ConfoState/blob/main/CONTRIBUTING.md) +at the repository root; this page summarizes style and tooling. + +## Style + +| Tool | Role | Project setting | +|------|------|-----------------| +| **Ruff** | Linter and formatter | Line length **79** (PEP 8) | + +All settings are in `pyproject.toml` (`[tool.ruff*]`). + +```bash +mamba activate confostate +pip install -e ".[lint]" + +ruff format . +ruff check --fix . +ruff check . +ruff format --check --diff . +``` + +## Pre-commit + +Install hooks once so every commit is formatted and linted: + +```bash +pip install -e ".[lint]" +pre-commit install +pre-commit run --all-files # optional: check the whole tree +``` + +See `.pre-commit-config.yaml`. CI runs Ruff on every push and pull request to +`main` / `develop`. diff --git a/docs/index.md b/docs/index.md index d16da52..31af351 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,13 +9,16 @@ described in the literature. USAGE api +development scripts/download_structures LeuT_descriptors ``` + ## Quick start ```bash pip install -e . ``` -See {doc}`USAGE` for workflows and {doc}`api` for the Python API. +See {doc}`USAGE` for workflows, {doc}`api` for the Python API, and +{doc}`development` for coding style and pre-commit. diff --git a/pyproject.toml b/pyproject.toml index f6cad5f..f254840 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,10 @@ docs = [ "furo>=2024.1.0", "sphinx-copybutton>=0.5", ] +lint = [ + "ruff>=0.9", + "pre-commit>=3.0", +] dev = [ "pytest>=7.0", "pytest-cov>=4.0", @@ -36,8 +40,8 @@ dev = [ "myst-parser>=2.0", "furo>=2024.1.0", "sphinx-copybutton>=0.5", - "black>=22.0", - "flake8>=4.0", + "ruff>=0.9", + "pre-commit>=3.0", ] [tool.setuptools] @@ -53,3 +57,27 @@ addopts = "-ra" [tool.coverage.run] source = ["confostate"] branch = true + +# All code-style settings live here (ruff lint + format). +[tool.ruff] +line-length = 79 +target-version = "py39" +include = ["*.py", "*.pyi"] +extend-exclude = [ + "docs/_build", + "Plans", +] + +[tool.ruff.lint] +# Flake8-compatible core rules (pycodestyle + pyflakes). +select = ["E", "F", "W"] + +[tool.ruff.lint.per-file-ignores] +# Example scripts may adjust sys.path before importing the package. +"examples/*" = ["E402"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" From adf55f9222285e1d45fb0d4f5de25a7a59408a64 Mon Sep 17 00:00:00 2001 From: Oliver Beckstein Date: Mon, 27 Jul 2026 15:41:00 -0700 Subject: [PATCH 09/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index ef31791..7d692c1 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -88,7 +88,7 @@ jobs: run: | python -m pip install --upgrade pip WHEEL_FILE=$(ls dist/*.whl) - pip install "${WHEEL_FILE}[test]" + pip install "confostate[test] @ file://$(pwd)/${WHEEL_FILE}" - name: Test import run: | From 4e8a59970c16e766bc5796fc70f43126d4de2f18 Mon Sep 17 00:00:00 2001 From: Oliver Beckstein Date: Mon, 27 Jul 2026 16:58:03 -0700 Subject: [PATCH 10/18] Only test Python 3.11-3.14 on Linux --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1a0b4df..8b09f6e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 From c5ef0f57e90dc13c46bcb69830a15e8c5d281f8c Mon Sep 17 00:00:00 2001 From: Amruthesh Thirumalaiswamy Date: Mon, 13 Jul 2026 13:15:06 -0700 Subject: [PATCH 11/18] Add: Feature extraction pipeline with MDAnalysis - Implement cavity, domain, RMSD, and orientation feature modules - Add `extract_features()` orchestration in pipeline.py - Add tests, docs, and `extract_feature_vectors` script - Include annotation template (.csv.example) and provenance docs --- .gitignore | 14 +- Plans/person2-feature-engineering-plan.md | 64 ++++++ confostate/__init__.py | 3 +- confostate/features/__init__.py | 13 ++ confostate/features/_structure.py | 129 +++++++++++ confostate/features/cavity.py | 86 +++++++ confostate/features/domains.py | 76 ++++++ confostate/features/orientation.py | 105 +++++++++ confostate/features/pipeline.py | 82 +++++++ confostate/features/rmsd.py | 97 ++++++++ data/annotations/README.md | 47 ++++ .../leu_t_transporters.csv.example | 26 +++ docs/features.md | 216 ++++++++++++++++++ docs/scripts/extract_feature_vectors.md | 44 ++++ pyproject.toml | 7 +- scripts/extract_feature_vectors.py | 67 ++++++ tests/test_features.py | 79 +++++++ 17 files changed, 1149 insertions(+), 6 deletions(-) create mode 100644 Plans/person2-feature-engineering-plan.md create mode 100644 confostate/features/__init__.py create mode 100644 confostate/features/_structure.py create mode 100644 confostate/features/cavity.py create mode 100644 confostate/features/domains.py create mode 100644 confostate/features/orientation.py create mode 100644 confostate/features/pipeline.py create mode 100644 confostate/features/rmsd.py create mode 100644 data/annotations/README.md create mode 100644 data/annotations/leu_t_transporters.csv.example create mode 100644 docs/features.md create mode 100644 docs/scripts/extract_feature_vectors.md create mode 100644 scripts/extract_feature_vectors.py create mode 100644 tests/test_features.py diff --git a/.gitignore b/.gitignore index cf314ea..771570a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Python __pycache__/ +.pytest_cache/ *.py[cod] *.pyo .venv/ @@ -27,9 +28,16 @@ data/structures/ *.ent *.ent.gz -# Data outputs (generated files) -data/annotations/*.csv -data/annotations/*.json +# Annotations — stub CSV is local-only until Person 1 verifies (see data/annotations/README.md) +data/annotations/* +!data/annotations/README.md +!data/annotations/leu_t_transporters.csv.example + +# Generated feature vectors — run scripts/extract_feature_vectors.py locally +data/features/ + +# Downloaded PDBs — fetch with scripts/download_structures.py +input/ # OS / editor .DS_Store diff --git a/Plans/person2-feature-engineering-plan.md b/Plans/person2-feature-engineering-plan.md new file mode 100644 index 0000000..41a3e6b --- /dev/null +++ b/Plans/person2-feature-engineering-plan.md @@ -0,0 +1,64 @@ +# Person 2 Feature Engineering Plan + +**Date:** 2026-07-13 +**Status:** Completed +**Assignee:** Amru (Person 2A) + +## Goal + +Implement Phase 2 feature extraction for ConfoState: structural descriptors from PDB +files, unified `extract_features()` API, tests, and example feature vectors for 25 +LeuT transporters. + +## Approach + +### Modules implemented + +| Module | File | Features | +|--------|------|----------| +| Cavity | `confostate/features/cavity.py` | `cavity_volume`, `cavity_accessibility_in/out` | +| Domains | `confostate/features/domains.py` | TM helix pairwise distances and angles | +| RMSD | `confostate/features/rmsd.py` | RMSD to 4 reference structures per state | +| Orientation | `confostate/features/orientation.py` | OPM tilt/rotation/depth (+ computed fallback) | +| Shared | `confostate/features/_structure.py` | PDB parsing, Kabsch RMSD, geometry helpers | + +### API + +- `extract_features(pdb_path)` — single structure +- `extract_features_batch(pdb_paths, annotations_df)` — batch → DataFrame +- `scripts/extract_feature_vectors.py` — CLI for all 25 LeuT structures + +### Data dependencies (Person 1) + +Created `data/annotations/leu_t_transporters.csv` with literature-based state labels +and placeholder OPM columns. Person 1 can replace OPM values with authoritative +OPM API data without changing feature code. + +## Key decisions + +| Decision | Rationale | +|----------|-----------| +| Convex hull for cavity volume | Practical without Hollow (Python 2.7); documented as future upgrade | +| LeuT-specific residue/helix defs | Matches workplan scope; parameterized for future families | +| OPM from CSV with geometric fallback | Unblocks work when Person 1 OPM pipeline not merged | +| BioPython + SciPy | Standard structural biology stack | +| Reference RMSD on shared scaffold residues | Robust to missing loops/ligands | + +## Deliverables + +- `confostate/features/` (4 feature modules + `extract_features`) +- `tests/test_features.py` (6 tests, all passing) +- `docs/features.md` +- `data/features/leu_t_feature_vectors.csv` (25 structures) +- `scripts/extract_feature_vectors.py` + doc + +## Not implemented (optional / future) + +- `symmetry.py` — internal repeat symmetry (marked optional in workplan) +- Hollow-based pore detection — requires Python 3 rewrite of Hollow + +## Coordination notes for team + +- **Person 1:** Confirm binding-site residue list and OPM column names match curated CSV. +- **Person 3:** Use `data/features/leu_t_feature_vectors.csv` or call `extract_features()` directly. +- **Person 2B (Marshal):** API agreed: flat `dict[str, float]` from `extract_features()`. diff --git a/confostate/__init__.py b/confostate/__init__.py index 96993f4..dc646bb 100644 --- a/confostate/__init__.py +++ b/confostate/__init__.py @@ -3,5 +3,6 @@ __version__ = "0.1.0" from confostate.data.loader import load_annotations +from confostate.features import extract_features -__all__ = ["load_annotations"] +__all__ = ["load_annotations", "extract_features"] diff --git a/confostate/features/__init__.py b/confostate/features/__init__.py new file mode 100644 index 0000000..f370720 --- /dev/null +++ b/confostate/features/__init__.py @@ -0,0 +1,13 @@ +"""Feature extraction for membrane protein conformational state classification.""" + +from confostate.features.pipeline import ( + FEATURE_GROUPS, + extract_features, + extract_features_batch, +) + +__all__ = [ + "extract_features", + "extract_features_batch", + "FEATURE_GROUPS", +] diff --git a/confostate/features/_structure.py b/confostate/features/_structure.py new file mode 100644 index 0000000..a0a3e24 --- /dev/null +++ b/confostate/features/_structure.py @@ -0,0 +1,129 @@ +"""MDAnalysis-based structure loading and geometry helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Optional + +import MDAnalysis as mda +import numpy as np +from MDAnalysis.core.groups import AtomGroup + + +@dataclass(frozen=True) +class StructureData: + """MDAnalysis universe wrapper for feature extraction.""" + + universe: mda.Universe + pdb_path: str + pdb_id: str + chain_id: Optional[str] = None + + @property + def primary_chain_id(self) -> str: + """First protein chain ID (handles dimers by picking chain A).""" + if self.chain_id: + return self.chain_id + chains = np.unique(self.universe.select_atoms("protein").chainIDs) + if len(chains) == 0: + raise ValueError(f"No protein chains found in {self.pdb_path}") + return str(chains[0]) + + @property + def ca_atoms(self) -> AtomGroup: + """Alpha-carbon atoms for the primary chain.""" + chain = self.primary_chain_id + ag = self.universe.select_atoms(f"protein and chainID {chain} and name CA") + if len(ag) == 0: + ag = self.universe.select_atoms(f"segid {chain} and name CA") + return ag + + def select_ca_range(self, start: int, end: int) -> AtomGroup: + return self.ca_atoms.select_atoms(f"resid {start}:{end}") + + def select_residues( + self, + resids: Iterable[int], + *, + name: str = "CA", + heavy_atoms: bool = False, + ) -> AtomGroup: + resid_str = " ".join(str(r) for r in resids) + chain = self.primary_chain_id + if heavy_atoms: + return self.universe.select_atoms( + f"protein and chainID {chain} and resid {resid_str} and not name H*" + ) + return self.ca_atoms.select_atoms(f"resid {resid_str}") + + +def infer_pdb_id(pdb_path: str, pdb_id: Optional[str] = None) -> str: + if pdb_id: + return pdb_id.upper() + return Path(pdb_path).stem.upper() + + +def load_structure( + pdb_path: str, + pdb_id: Optional[str] = None, + chain_id: Optional[str] = None, +) -> StructureData: + """Load a PDB file as an MDAnalysis Universe.""" + path = Path(pdb_path) + if not path.exists(): + raise FileNotFoundError(f"PDB file not found: {pdb_path}") + + universe = mda.Universe(str(path)) + structure = StructureData( + universe=universe, + pdb_path=str(path), + pdb_id=infer_pdb_id(pdb_path, pdb_id), + chain_id=chain_id, + ) + if len(structure.ca_atoms) == 0: + raise ValueError(f"No CA atoms found in {pdb_path}") + return structure + + +def sort_by_resid(atomgroup: AtomGroup) -> AtomGroup: + """Return atom group sorted by residue number.""" + return atomgroup[np.argsort(atomgroup.resids)] + + +def center_of_mass(atomgroup: AtomGroup) -> np.ndarray: + if len(atomgroup) == 0: + raise ValueError("Cannot compute center of mass for empty atom group") + return atomgroup.center_of_mass() + + +def pairwise_distance(a: np.ndarray, b: np.ndarray) -> float: + return float(np.linalg.norm(a - b)) + + +def angle_between_vectors(a: np.ndarray, b: np.ndarray) -> float: + """Return angle in degrees between two 3D vectors.""" + a_norm = np.linalg.norm(a) + b_norm = np.linalg.norm(b) + if a_norm == 0 or b_norm == 0: + return float("nan") + cos_angle = np.clip(np.dot(a, b) / (a_norm * b_norm), -1.0, 1.0) + return float(np.degrees(np.arccos(cos_angle))) + + +def principal_axis(coords: np.ndarray) -> np.ndarray: + """First principal component of coordinate cloud (unit vector).""" + centered = coords - coords.mean(axis=0) + _, _, vh = np.linalg.svd(centered, full_matrices=False) + axis = vh[0] + norm = np.linalg.norm(axis) + if norm == 0: + return np.array([0.0, 0.0, 1.0]) + return axis / norm + + +def helix_axis(atomgroup: AtomGroup) -> np.ndarray: + """Helix axis from CA positions (first principal component).""" + if len(atomgroup) < 2: + return np.array([0.0, 0.0, 1.0]) + return principal_axis(atomgroup.positions) diff --git a/confostate/features/cavity.py b/confostate/features/cavity.py new file mode 100644 index 0000000..558732a --- /dev/null +++ b/confostate/features/cavity.py @@ -0,0 +1,86 @@ +"""Cavity and solvent-accessibility features for substrate-binding sites.""" + +from __future__ import annotations + +from typing import Optional + +import numpy as np +from scipy.spatial import ConvexHull + +from confostate.features._structure import StructureData, center_of_mass + +# LeuT substrate / Na1 binding pocket residues (Yamashita et al. 2005, Singh et al. 2008). +LEUT_BINDING_SITE_RESIDUES = ( + 21, 22, 23, 24, 55, 58, 91, 93, 108, 152, 156, 158, 256, 259, 319, 322, +) + +ACCESSIBILITY_RADIUS = 12.0 +ACCESSIBILITY_SLAB_HEIGHT = 8.0 + + +def _convex_hull_volume(coords: np.ndarray) -> float: + if len(coords) < 4: + return 0.0 + try: + hull = ConvexHull(coords) + return float(hull.volume) + except Exception: + return 0.0 + + +def _accessibility_along_axis( + structure: StructureData, + site_center: np.ndarray, + membrane_normal: np.ndarray, + direction: float, +) -> float: + """Fraction of CA atoms within a slab on one side of the binding site.""" + normal = membrane_normal / np.linalg.norm(membrane_normal) + ca_coords = structure.ca_atoms.positions + relative = ca_coords - site_center + projections = relative @ normal + slab_mask = (projections * direction > 0) & (np.abs(projections) < ACCESSIBILITY_SLAB_HEIGHT) + if not slab_mask.any(): + return 0.0 + + slab_coords = ca_coords[slab_mask] + lateral = slab_coords - site_center + lateral = lateral - np.outer(lateral @ normal, normal) + distances = np.linalg.norm(lateral, axis=1) + exposed = (distances < ACCESSIBILITY_RADIUS).sum() + return float(exposed / len(slab_coords)) + + +def extract_cavity_features( + structure: StructureData, + membrane_normal: Optional[np.ndarray] = None, + binding_residues: tuple[int, ...] = LEUT_BINDING_SITE_RESIDUES, +) -> dict[str, float]: + """ + Compute cavity volume and inward/outward accessibility proxies. + + Uses MDAnalysis atom selections for binding-site atoms and CA positions. + """ + if membrane_normal is None: + membrane_normal = np.array([0.0, 0.0, 1.0]) + + binding_atoms = structure.select_residues(binding_residues, heavy_atoms=True) + if len(binding_atoms) == 0: + binding_atoms = structure.select_residues(binding_residues) + + if len(binding_atoms) > 0: + site_center = center_of_mass(binding_atoms) + volume_coords = binding_atoms.positions + else: + site_center = center_of_mass(structure.ca_atoms) + volume_coords = structure.ca_atoms.positions + + return { + "cavity_volume": _convex_hull_volume(volume_coords), + "cavity_accessibility_in": _accessibility_along_axis( + structure, site_center, membrane_normal, direction=-1.0 + ), + "cavity_accessibility_out": _accessibility_along_axis( + structure, site_center, membrane_normal, direction=1.0 + ), + } diff --git a/confostate/features/domains.py b/confostate/features/domains.py new file mode 100644 index 0000000..efe3948 --- /dev/null +++ b/confostate/features/domains.py @@ -0,0 +1,76 @@ +"""Domain distance and angle features for LeuT transporters.""" + +from __future__ import annotations + +from typing import Optional + +import numpy as np + +from confostate.features._structure import ( + StructureData, + angle_between_vectors, + center_of_mass, + helix_axis, + pairwise_distance, +) + +LEUT_TM_HELICES: dict[str, tuple[int, int]] = { + "TM1": (22, 52), + "TM2": (68, 98), + "TM3": (101, 133), + "TM4": (144, 174), + "TM5": (188, 218), + "TM6": (248, 278), + "TM7": (287, 317), + "TM8": (328, 358), + "TM9": (371, 401), + "TM10": (412, 442), + "TM11": (455, 485), + "TM12": (496, 515), +} + +LEUT_DOMAIN_PAIRS = ( + ("TM1", "TM7"), + ("TM1", "TM6"), + ("TM5", "TM7"), + ("TM3", "TM10"), +) + + +def extract_domain_features( + structure: StructureData, + tm_helices: Optional[dict[str, tuple[int, int]]] = None, + domain_pairs: tuple[tuple[str, str], ...] = LEUT_DOMAIN_PAIRS, +) -> dict[str, float]: + """ + Compute pairwise helix COM distances and inter-helix angles. + + Uses MDAnalysis selections and ``AtomGroup.center_of_mass()``. + """ + helices = tm_helices or LEUT_TM_HELICES + features: dict[str, float] = {} + + coms: dict[str, np.ndarray] = {} + axes: dict[str, np.ndarray] = {} + for name, (start, end) in helices.items(): + ag = structure.select_ca_range(start, end) + if len(ag) == 0: + continue + coms[name] = center_of_mass(ag) + axes[name] = helix_axis(ag) + + for helix_a, helix_b in domain_pairs: + key_base = f"domain_{helix_a}_{helix_b}" + if helix_a not in coms or helix_b not in coms: + features[f"{key_base}_distance"] = float("nan") + features[f"{key_base}_angle"] = float("nan") + continue + features[f"{key_base}_distance"] = pairwise_distance(coms[helix_a], coms[helix_b]) + features[f"{key_base}_angle"] = angle_between_vectors(axes[helix_a], axes[helix_b]) + + if "TM1" in coms and "TM6" in coms: + features["domain_gate_TM1_TM6_distance"] = pairwise_distance(coms["TM1"], coms["TM6"]) + else: + features["domain_gate_TM1_TM6_distance"] = float("nan") + + return features diff --git a/confostate/features/orientation.py b/confostate/features/orientation.py new file mode 100644 index 0000000..925b2f7 --- /dev/null +++ b/confostate/features/orientation.py @@ -0,0 +1,105 @@ +"""Membrane orientation features (OPM-derived or computed).""" + +from __future__ import annotations + +from typing import Any, Optional + +import numpy as np + +from confostate.features._structure import StructureData, angle_between_vectors, principal_axis + +DEFAULT_MEMBRANE_NORMAL = np.array([0.0, 0.0, 1.0]) + +_MISSING_VALUES = frozenset({"", "na", "n/a", "none", "null", "nan", "not_fetched", "pending"}) + + +def _is_missing(value: Any) -> bool: + if value is None: + return True + if isinstance(value, float) and np.isnan(value): + return True + return str(value).strip().lower() in _MISSING_VALUES + + +def _maybe_float(value: Any) -> Optional[float]: + if _is_missing(value): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _tilt_angle(protein_axis: np.ndarray, membrane_normal: np.ndarray) -> float: + return angle_between_vectors(protein_axis, membrane_normal) + + +def _rotation_angle(protein_axis: np.ndarray, membrane_normal: np.ndarray) -> float: + normal = membrane_normal / np.linalg.norm(membrane_normal) + projected = protein_axis - np.dot(protein_axis, normal) * normal + proj_norm = np.linalg.norm(projected) + if proj_norm < 1e-6: + return 0.0 + projected /= proj_norm + ref = np.array([1.0, 0.0, 0.0]) + ref = ref - np.dot(ref, normal) * normal + ref_norm = np.linalg.norm(ref) + if ref_norm < 1e-6: + ref = np.array([0.0, 1.0, 0.0]) + ref = ref - np.dot(ref, normal) * normal + ref /= np.linalg.norm(ref) + else: + ref /= ref_norm + cos_angle = np.clip(np.dot(projected, ref), -1.0, 1.0) + angle = float(np.degrees(np.arccos(cos_angle))) + cross = np.cross(ref, projected) + if np.dot(cross, normal) < 0: + angle = 360.0 - angle + return angle + + +def _membrane_depth(structure: StructureData, membrane_normal: np.ndarray) -> float: + normal = membrane_normal / np.linalg.norm(membrane_normal) + centroid = structure.ca_atoms.center_of_mass() + return float(abs(np.dot(centroid, normal))) + + +def extract_orientation_features( + structure: StructureData, + annotations_row: Optional[dict[str, Any]] = None, + membrane_normal: Optional[np.ndarray] = None, +) -> dict[str, float]: + """ + Extract membrane orientation features. + + OPM columns from annotations are used when present; otherwise values are + computed from MDAnalysis CA coordinates. + """ + normal = membrane_normal if membrane_normal is not None else DEFAULT_MEMBRANE_NORMAL.copy() + axis = principal_axis(structure.ca_atoms.positions) + + features: dict[str, float] = {} + + if annotations_row: + for col in ("opm_tilt_angle", "opm_rotation_angle", "opm_depth", "opm_tm_count"): + parsed = _maybe_float(annotations_row.get(col)) + if parsed is not None: + features[col] = parsed + + if "opm_tilt_angle" not in features: + features["opm_tilt_angle"] = _tilt_angle(axis, normal) + if "opm_rotation_angle" not in features: + features["opm_rotation_angle"] = _rotation_angle(axis, normal) + if "opm_depth" not in features: + features["opm_depth"] = _membrane_depth(structure, normal) + + features["orientation_principal_axis_x"] = float(axis[0]) + features["orientation_principal_axis_y"] = float(axis[1]) + features["orientation_principal_axis_z"] = float(axis[2]) + + return features + + +def get_membrane_normal(annotations_row: Optional[dict[str, Any]] = None) -> np.ndarray: + """Return membrane normal vector, defaulting to Z-axis.""" + return DEFAULT_MEMBRANE_NORMAL.copy() diff --git a/confostate/features/pipeline.py b/confostate/features/pipeline.py new file mode 100644 index 0000000..c311e35 --- /dev/null +++ b/confostate/features/pipeline.py @@ -0,0 +1,82 @@ +"""Feature extraction pipeline — orchestrates per-module extractors.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Optional + +import pandas as pd + +from confostate.features._structure import load_structure +from confostate.features.cavity import extract_cavity_features +from confostate.features.domains import extract_domain_features +from confostate.features.orientation import extract_orientation_features, get_membrane_normal +from confostate.features.rmsd import extract_rmsd_features + +FEATURE_GROUPS = ("cavity", "domains", "rmsd", "orientation") + + +def extract_features( + pdb_path: str, + pdb_id: Optional[str] = None, + family: str = "LeuT", + annotations_row: Optional[dict[str, Any]] = None, + reference_dir: Optional[str] = None, + include_rmsd: bool = True, +) -> dict[str, float]: + """ + Extract all structural features from a PDB file. + + Loads the structure once via MDAnalysis, then runs each feature module. + """ + structure = load_structure(pdb_path, pdb_id=pdb_id) + membrane_normal = get_membrane_normal(annotations_row) + + features: dict[str, float] = {} + + features.update(extract_cavity_features(structure, membrane_normal=membrane_normal)) + features.update(extract_domain_features(structure)) + features.update(extract_orientation_features(structure, annotations_row=annotations_row)) + + if include_rmsd: + try: + features.update( + extract_rmsd_features( + structure, + reference_dir=reference_dir or str(Path(pdb_path).parent), + ) + ) + except FileNotFoundError: + pass + + return features + + +def extract_features_batch( + pdb_paths: list[str], + annotations_df: Optional[pd.DataFrame] = None, + reference_dir: Optional[str] = None, +) -> pd.DataFrame: + """Extract features for multiple PDB files; returns one row per structure.""" + rows = [] + for pdb_path in pdb_paths: + pdb_id = Path(pdb_path).stem.upper() + row_data: Optional[dict[str, Any]] = None + if annotations_df is not None and "pdb_id" in annotations_df.columns: + matches = annotations_df[annotations_df["pdb_id"].str.upper() == pdb_id] + if len(matches) > 0: + row_data = matches.iloc[0].to_dict() + + features = extract_features( + pdb_path, + pdb_id=pdb_id, + annotations_row=row_data, + reference_dir=reference_dir, + ) + features["pdb_id"] = pdb_id + features["file_path"] = pdb_path + if row_data and "conformation" in row_data: + features["conformation"] = row_data["conformation"] + rows.append(features) + + return pd.DataFrame(rows) diff --git a/confostate/features/rmsd.py b/confostate/features/rmsd.py new file mode 100644 index 0000000..e4a08a4 --- /dev/null +++ b/confostate/features/rmsd.py @@ -0,0 +1,97 @@ +"""RMSD-to-reference features for conformational state comparison.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import numpy as np +from MDAnalysis.analysis import rms + +from confostate.features._structure import StructureData, load_structure + +LEUT_REFERENCE_STRUCTURES: dict[str, str] = { + "OF_open": "3F3E", + "IF_open": "3F3A", + "Occluded": "3F4J", + "Intermediate": "3USI", +} + +LEUT_ALIGNMENT_RESIDUES = tuple(range(50, 480, 5)) + + +def _aligned_ca_groups( + mobile: StructureData, + reference: StructureData, + alignment_residues: tuple[int, ...], +): + mobile_ids = set(mobile.ca_atoms.resids) + ref_ids = set(reference.ca_atoms.resids) + common = sorted(mobile_ids & ref_ids & set(alignment_residues)) + if len(common) < 3: + raise ValueError("Insufficient shared residues for RMSD alignment") + + mobile_coords = [] + ref_coords = [] + for resid in common: + m_ag = mobile.ca_atoms.select_atoms(f"resid {resid}") + r_ag = reference.ca_atoms.select_atoms(f"resid {resid}") + if len(m_ag) == 0 or len(r_ag) == 0: + continue + mobile_coords.append(m_ag[0].position) + ref_coords.append(r_ag[0].position) + + if len(mobile_coords) < 3: + raise ValueError("Insufficient shared residues for RMSD alignment") + + return np.asarray(mobile_coords), np.asarray(ref_coords) + + +def _resolve_reference_path(pdb_id: str, reference_dir: Optional[str]) -> Path: + candidates = [] + if reference_dir: + candidates.append(Path(reference_dir) / f"{pdb_id}.pdb") + candidates.extend([ + Path("input") / f"{pdb_id}.pdb", + Path("data/structures") / f"{pdb_id}.pdb", + ]) + for path in candidates: + if path.exists(): + return path + raise FileNotFoundError( + f"Reference structure {pdb_id}.pdb not found. " + f"Searched: {', '.join(str(p) for p in candidates)}" + ) + + +def extract_rmsd_features( + structure: StructureData, + reference_structures: Optional[dict[str, str]] = None, + reference_dir: Optional[str] = None, + alignment_residues: tuple[int, ...] = LEUT_ALIGNMENT_RESIDUES, +) -> dict[str, float]: + """ + Compute RMSD to reference structures using MDAnalysis ``rms.rmsd``. + + Superposition is performed automatically (Kabsch algorithm). + """ + refs = reference_structures or LEUT_REFERENCE_STRUCTURES + features: dict[str, float] = {} + rmsd_values: list[float] = [] + + for state, ref_pdb_id in refs.items(): + ref_path = _resolve_reference_path(ref_pdb_id, reference_dir) + reference = load_structure(str(ref_path), pdb_id=ref_pdb_id) + mobile_coords, ref_coords = _aligned_ca_groups(structure, reference, alignment_residues) + rmsd_val = rms.rmsd(mobile_coords, ref_coords, superposition=True) + features[f"rmsd_{state}"] = float(rmsd_val) + rmsd_values.append(float(rmsd_val)) + + features["rmsd_min"] = float(min(rmsd_values)) if rmsd_values else float("nan") + if rmsd_values: + best_state = min(refs.keys(), key=lambda s: features[f"rmsd_{s}"]) + features["rmsd_best_state_index"] = float(list(refs.keys()).index(best_state)) + else: + features["rmsd_best_state_index"] = float("nan") + + return features diff --git a/data/annotations/README.md b/data/annotations/README.md new file mode 100644 index 0000000..682b10b --- /dev/null +++ b/data/annotations/README.md @@ -0,0 +1,47 @@ +# LeuT Annotations — Data Provenance + +This file is a **working stub** until Person 1 (Josh) delivers verified curation. + +**Fresh clone setup:** + +```bash +cp data/annotations/leu_t_transporters.csv.example \ + data/annotations/leu_t_transporters.csv +``` + +The `.csv` file is gitignored; the `.csv.example` template is committed. + +## Column status + +| Column | Status | Source | +|--------|--------|--------| +| `pdb_id` | **Real** | `data/protein_families/LeuT_transporters.txt` | +| `family` | **Real** | Project definition (LeuT) | +| `conformation` | **Unverified estimate** | Assigned from known LeuT papers; **not** re-checked against each primary source | +| `conformation_status` | Meta | `literature_estimate` = needs Person 1 verification | +| `reference` | **Approximate** | Paper associated with each structure family; not linked to DOI/PubMed yet | +| `experimental_method`, `resolution_angstrom`, `year` | **Approximate** | Typical values from PDB/literature memory; **not** fetched live from RCSB API | +| `metadata_status` | Meta | `literature_estimate` = needs RCSB API verification (Person 1 task) | +| `opm_*` columns | **Placeholder** | `N/A` — OPM not fetched yet (Person 1 task) | +| `opm_status` | Meta | `not_fetched` | + +## What is computed live (not in this CSV) + +Feature vectors in `data/features/leu_t_feature_vectors.csv` are **computed at runtime** by: + +1. Downloading PDB coordinates from RCSB (`scripts/download_structures.py`) +2. Running `confostate.features.extract_features()` on each file + +Computed from PDB coordinates (MDAnalysis + SciPy): + +- `cavity_*` — binding-site geometry +- `domain_*` — TM helix distances/angles +- `rmsd_*` — superposed RMSD vs reference structures +- `opm_*` (in feature output) — **computed from structure** when CSV has `N/A` +- `orientation_principal_axis_*` — computed from structure + +## Who fixes what + +- **Person 1:** Verify conformation labels, fetch RCSB metadata, fetch real OPM values +- **Person 2:** Feature extraction from PDB files (almost done) +- **Person 3:** Uses feature vectors + `conformation` labels for ML training diff --git a/data/annotations/leu_t_transporters.csv.example b/data/annotations/leu_t_transporters.csv.example new file mode 100644 index 0000000..8935631 --- /dev/null +++ b/data/annotations/leu_t_transporters.csv.example @@ -0,0 +1,26 @@ +pdb_id,family,conformation,conformation_status,reference,experimental_method,resolution_angstrom,year,metadata_status,opm_tilt_angle,opm_rotation_angle,opm_depth,opm_tm_count,opm_status +3F3A,LeuT,IF_open,literature_estimate,Yamashita et al. 2005,X-ray,1.65,2005,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3F3C,LeuT,IF_open,literature_estimate,Yamashita et al. 2005,X-ray,2.00,2005,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3F3D,LeuT,IF_open,literature_estimate,Yamashita et al. 2005,X-ray,1.95,2005,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3F3E,LeuT,OF_open,literature_estimate,Singh et al. 2008,X-ray,1.80,2008,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3F4I,LeuT,Occluded,literature_estimate,Shi et al. 2008,X-ray,2.40,2008,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3F4J,LeuT,Occluded,literature_estimate,Shi et al. 2008,X-ray,2.20,2008,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3GJC,LeuT,Intermediate,literature_estimate,Weyand et al. 2008,X-ray,2.10,2008,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3GJD,LeuT,IF_open,literature_estimate,Weyand et al. 2008,X-ray,2.30,2008,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3MPN,LeuT,IF_open,literature_estimate,Krishnamurthy & Gouaux 2012,X-ray,2.10,2012,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3MPQ,LeuT,OF_open,literature_estimate,Krishnamurthy & Gouaux 2012,X-ray,2.00,2012,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3QS4,LeuT,Occluded,literature_estimate,Penmatsa et al. 2013,X-ray,2.80,2013,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3QS5,LeuT,OF_open,literature_estimate,Penmatsa et al. 2013,X-ray,2.50,2013,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3QS6,LeuT,OF_open,literature_estimate,Penmatsa et al. 2013,X-ray,2.60,2013,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3TT1,LeuT,IF_open,literature_estimate,Claxton et al. 2010,X-ray,2.10,2010,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3TT3,LeuT,IF_open,literature_estimate,Claxton et al. 2010,X-ray,2.30,2010,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3TU0,LeuT,IF_open,literature_estimate,Claxton et al. 2010,X-ray,2.40,2010,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3USI,LeuT,Intermediate,literature_estimate,Claxton et al. 2010,X-ray,2.50,2010,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3USL,LeuT,Intermediate,literature_estimate,Claxton et al. 2010,X-ray,2.60,2010,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3USM,LeuT,Occluded,literature_estimate,Claxton et al. 2010,X-ray,2.40,2010,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3USO,LeuT,Occluded,literature_estimate,Claxton et al. 2010,X-ray,2.50,2010,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +3USP,LeuT,Occluded,literature_estimate,Claxton et al. 2010,X-ray,2.55,2010,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +5JAE,LeuT,OF_open,literature_estimate,Methot et al. 2015,X-ray,2.80,2015,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +5JAF,LeuT,OF_open,literature_estimate,Methot et al. 2015,X-ray,2.90,2015,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +5JAG,LeuT,Occluded,literature_estimate,Methot et al. 2015,X-ray,3.00,2015,literature_estimate,N/A,N/A,N/A,N/A,not_fetched +6XWM,LeuT,IF_open,literature_estimate,Coleman et al. 2020,Cryo-EM,3.20,2020,literature_estimate,N/A,N/A,N/A,N/A,not_fetched diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..5b4b091 --- /dev/null +++ b/docs/features.md @@ -0,0 +1,216 @@ +# Feature Extraction + +ConfoState extracts numerical structural descriptors from membrane protein PDB +files using [MDAnalysis](https://www.mdanalysis.org/) for structure loading, +atom selections, center-of-mass, and RMSD superposition. + +These features feed the ML classifier (Person 3) and explainability layer +(Person 4). + +## Package layout + +``` +confostate/features/ +├── __init__.py # Public API re-exports only +├── pipeline.py # extract_features() orchestration +├── _structure.py # MDAnalysis Universe wrapper +├── cavity.py # Binding-site volume & accessibility +├── domains.py # TM helix distances & angles +├── rmsd.py # RMSD to reference structures +└── orientation.py # Membrane orientation features +``` + +`__init__.py` is intentionally thin — it re-exports the public API. +Orchestration lives in `pipeline.py`; each feature type has its own module. + +## Quick start + +```python +from confostate.features import extract_features + +features = extract_features("input/3F3E.pdb", pdb_id="3F3E") +print(features["cavity_volume"]) +print(features["rmsd_IF_open"]) +``` + +Batch extraction for all annotated LeuT structures: + +```bash +python scripts/download_structures.py \ + --codes-file data/protein_families/LeuT_transporters.txt \ + --output-dir input + +python scripts/extract_feature_vectors.py +``` + +Output: `data/features/leu_t_feature_vectors.csv` (generated locally; gitignored) + +## Data provenance & current status (Person 3) + +**Read this before training.** Not all columns in the feature table are equally trustworthy. + +### What you get in `leu_t_feature_vectors.csv` + +Each row = one PDB structure. Columns fall into three categories: + +| Category | Columns | Status | Source | +|----------|---------|--------|--------| +| **Computed features (X)** | `cavity_*`, `domain_*`, `rmsd_*`, `opm_*`, `orientation_principal_axis_*` | **Live computation** | MDAnalysis + SciPy on PDB coordinates from RCSB | +| **Label (y)** | `conformation` | **Unverified stub** | Copied from annotations CSV; `conformation_status = literature_estimate` | +| **Metadata** | `pdb_id`, `file_path` | **Real** | PDB ID list + local file path | + +### Annotations CSV (`data/annotations/leu_t_transporters.csv`) + +This file is a **local working stub** (gitignored) until Person 1 verifies it. +See `data/annotations/README.md` for full column-level provenance. + +| Field | Current status | +|-------|----------------| +| `conformation` | Literature estimate — **do not treat as ground truth** for publication | +| `resolution_angstrom`, `year`, `experimental_method` | Approximate — not fetched from RCSB API | +| `opm_*` | `N/A` / `opm_status = not_fetched` — orientation features are **computed from structure** instead | + +### Safe to use now for pipeline development + +- All numeric feature columns (`cavity_*`, `domain_*`, `rmsd_*`, etc.) — reproducible from PDBs +- `conformation` as a **provisional label** to wire up `datasets.py`, train/test splits, and model code + +### Wait for Person 1 before trusting for results + +- Conformation labels (your **y** variable) +- RCSB metadata (resolution, method, year) +- Real OPM orientation values (will replace computed `opm_*` when available) + +### Regenerating data locally + +Stub annotations and feature vectors are **not committed** (see `.gitignore`). To rebuild: + +```bash +# 0. Copy annotation template (first time only) +cp data/annotations/leu_t_transporters.csv.example \ + data/annotations/leu_t_transporters.csv + +# 1. PDB coordinates (from RCSB) +python scripts/download_structures.py \ + --codes-file data/protein_families/LeuT_transporters.txt \ + --output-dir input + +# 2. Feature table (requires local annotations CSV from step 0) +python scripts/extract_feature_vectors.py +``` + +### Division of labour (reminder) + +``` +Person 1 → labels & metadata (y) annotations CSV +Person 2 → features (X) extract_features() / feature vectors CSV +Person 3 → join X + y, train model confostate/data/datasets.py, models/ +``` + + +### `extract_features(pdb_path, ...)` + +Main entry point in `confostate.features`. Returns a flat `dict[str, float]`. + +| Parameter | Description | +|---|---| +| `pdb_path` | Path to PDB file | +| `pdb_id` | Optional PDB ID (inferred from filename) | +| `family` | Protein family (`LeuT` supported) | +| `annotations_row` | Optional dict with OPM columns from annotations CSV | +| `reference_dir` | Directory with reference PDBs for RMSD | +| `include_rmsd` | Set `False` to skip RMSD if references unavailable | + +### `extract_features_batch(pdb_paths, annotations_df=None)` + +Returns a `pandas.DataFrame` with one row per structure. + +## Feature modules + +### Cavity (`confostate.features.cavity`) + +Binding-site volume and solvent-accessibility proxies. + +| Feature | Description | +|---|---| +| `cavity_volume` | Convex-hull volume (ų) of binding-pocket atoms | +| `cavity_accessibility_in` | Fraction of nearby CA atoms on inward membrane side | +| `cavity_accessibility_out` | Fraction of nearby CA atoms on outward membrane side | + +Binding-site residues for LeuT are defined in `LEUT_BINDING_SITE_RESIDUES` +(Yamashita et al. 2005; Singh et al. 2008). + +### Domains (`confostate.features.domains`) + +Inter-helix distances and angles for LeuT TM helices. + +| Feature | Description | +|---|---| +| `domain_TM1_TM7_distance` | COM distance between gate helices TM1 and TM7 | +| `domain_TM1_TM7_angle` | Angle between TM1 and TM7 helix axes | +| `domain_TM1_TM6_distance` | COM distance between TM1 and TM6 | +| `domain_TM5_TM7_distance` | COM distance between TM5 and TM7 | +| `domain_TM3_TM10_distance` | COM distance between TM3 and TM10 | +| `domain_gate_TM1_TM6_distance` | Gate-opening distance (TM1–TM6) | + +Helix boundaries are in `LEUT_TM_HELICES`. + +### RMSD (`confostate.features.rmsd`) + +RMSD after MDAnalysis Kabsch superposition (`MDAnalysis.analysis.rms.rmsd`) +to curated reference structures per state. + +| Feature | Description | +|---|---| +| `rmsd_OF_open` | RMSD to outward-open reference (3F3E) | +| `rmsd_IF_open` | RMSD to inward-open reference (3F3A) | +| `rmsd_Occluded` | RMSD to occluded reference (3F4J) | +| `rmsd_Intermediate` | RMSD to intermediate reference (3USI) | +| `rmsd_min` | Minimum RMSD across all references | +| `rmsd_best_state_index` | Index of closest reference state | + +Reference PDB files must be present in `reference_dir` (default: same directory +as the input structure). + +### Orientation (`confostate.features.orientation`) + +Membrane orientation features. Uses OPM columns from the annotations CSV when +available; otherwise estimates tilt/rotation from the structure principal axis. + +| Feature | Description | +|---|---| +| `opm_tilt_angle` | Tilt of protein axis relative to membrane normal (°) | +| `opm_rotation_angle` | Rotation in membrane plane (°) | +| `opm_depth` | Centroid depth relative to membrane plane (Å) | +| `orientation_principal_axis_x/y/z` | Unit vector of first principal component | + +## Dependencies on Person 1 (data) + +Person 1 will deliver a verified `data/annotations/leu_t_transporters.csv`. +Until then, use the local stub described in [Data provenance & current status](#data-provenance--current-status-person-3). + +- **Labels:** `conformation` (+ `conformation_status` column when verified) +- **Metadata:** resolution, method, year from RCSB API +- **OPM:** `opm_tilt_angle`, `opm_rotation_angle`, `opm_depth` (replaces computed fallbacks) + +Coordinate with Person 1 on binding-site residue definitions and OPM column +names as the annotation schema evolves. + +## Testing + +```bash +pip install -e ".[dev]" +pytest tests/test_features.py -v +``` + +Tests use `input/3F3E.pdb` (and `3F3A.pdb` for RMSD). Download with: + +```bash +python scripts/download_structures.py --codes 3F3E 3F3A 3F4J 3USI --output-dir input +``` + +## Future work + +- **Cavity**: Integrate Hollow-based pore detection (Python 3 rewrite). +- **Symmetry**: Internal repeat symmetry scores (`confostate.features.symmetry`). +- **Multi-family**: Parameterize helix definitions and binding sites per family. diff --git a/docs/scripts/extract_feature_vectors.md b/docs/scripts/extract_feature_vectors.md new file mode 100644 index 0000000..e7b608c --- /dev/null +++ b/docs/scripts/extract_feature_vectors.md @@ -0,0 +1,44 @@ +# extract_feature_vectors + +Batch-extract structural feature vectors for annotated LeuT transporter PDB files. + +## Synopsis + +```bash +python scripts/extract_feature_vectors.py [OPTIONS] +``` + +## Description + +Reads the LeuT annotations CSV and PDB files from an input directory, runs +`confostate.features.extract_features_batch()`, and writes a CSV of feature +vectors suitable for ML training (Person 3). + +Structures must be downloaded first with `scripts/download_structures.py`. + +## Options + +| Flag | Default | Description | +|---|---|---| +| `--annotations` | `data/annotations/leu_t_transporters.csv` | Annotations CSV path | +| `--input-dir` | `input` | Directory containing `.pdb` files | +| `--output` | `data/features/leu_t_feature_vectors.csv` | Output feature CSV path | + +## Examples + +```bash +cp data/annotations/leu_t_transporters.csv.example \ + data/annotations/leu_t_transporters.csv + +python scripts/download_structures.py \ + --codes-file data/protein_families/LeuT_transporters.txt \ + --output-dir input + +python scripts/extract_feature_vectors.py +``` + +## Output + +Writes a CSV to `data/features/leu_t_feature_vectors.csv` with one row per +structure. Columns include `pdb_id`, `conformation`, cavity/domain/RMSD/orientation +features, and `file_path`. diff --git a/pyproject.toml b/pyproject.toml index f254840..9b64106 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,8 @@ authors = [ dependencies = [ "pandas>=1.3.0", "numpy>=1.21.0", + "MDAnalysis>=2.4.0", + "scipy>=1.7.0", ] [project.optional-dependencies] @@ -44,8 +46,9 @@ dev = [ "pre-commit>=3.0", ] -[tool.setuptools] -packages = ["confostate"] +[tool.setuptools.packages.find] +where = ["."] +include = ["confostate*"] [tool.setuptools.package-data] confostate = ["data/**/*"] diff --git a/scripts/extract_feature_vectors.py b/scripts/extract_feature_vectors.py new file mode 100644 index 0000000..09e1c8f --- /dev/null +++ b/scripts/extract_feature_vectors.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +"""Extract feature vectors for LeuT transporter structures. + +Downloads PDB files if missing, then writes a CSV of feature vectors. +""" + +import argparse +from pathlib import Path + +from confostate.data.loader import load_annotations, load_from_input_dir +from confostate.features import extract_features_batch + +DEFAULT_ANNOTATIONS = "data/annotations/leu_t_transporters.csv" +DEFAULT_INPUT_DIR = "input" +DEFAULT_OUTPUT = "data/features/leu_t_feature_vectors.csv" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Extract feature vectors for LeuT structures.") + parser.add_argument( + "--annotations", + default=DEFAULT_ANNOTATIONS, + help=f"Annotations CSV (default: {DEFAULT_ANNOTATIONS})", + ) + parser.add_argument( + "--input-dir", + default=DEFAULT_INPUT_DIR, + help=f"Directory with PDB files (default: {DEFAULT_INPUT_DIR})", + ) + parser.add_argument( + "--output", + default=DEFAULT_OUTPUT, + help=f"Output CSV path (default: {DEFAULT_OUTPUT})", + ) + args = parser.parse_args() + + annotations = load_annotations(args.annotations, family="LeuT") + structures = load_from_input_dir(args.input_dir) + + if len(structures) == 0: + raise SystemExit( + f"No PDB files in {args.input_dir}. Download with:\n" + f" python scripts/download_structures.py " + f"--codes-file data/protein_families/LeuT_transporters.txt " + f"--output-dir {args.input_dir}" + ) + + merged = annotations.merge(structures, on="pdb_id", how="inner") + if len(merged) == 0: + raise SystemExit("No overlap between annotations and downloaded PDB files.") + + print(f"Extracting features for {len(merged)} structures...") + df = extract_features_batch( + merged["file_path"].tolist(), + annotations_df=annotations, + reference_dir=args.input_dir, + ) + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(output_path, index=False) + print(f"Wrote {len(df)} feature vectors to {output_path}") + print(f"Feature columns: {len([c for c in df.columns if c not in ('pdb_id', 'file_path', 'conformation')])}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 0000000..ab6be0d --- /dev/null +++ b/tests/test_features.py @@ -0,0 +1,79 @@ +"""Tests for confostate.features package.""" + +from pathlib import Path + +import pytest + +from confostate.data.loader import load_annotations +from confostate.features import extract_features, extract_features_batch +from confostate.features._structure import load_structure +from confostate.features.cavity import extract_cavity_features +from confostate.features.domains import extract_domain_features +from confostate.features.orientation import extract_orientation_features +from confostate.features.rmsd import extract_rmsd_features + +INPUT_DIR = Path(__file__).resolve().parent.parent / "input" +ANNOTATIONS = Path(__file__).resolve().parent.parent / "data/annotations/leu_t_transporters.csv" + + +@pytest.fixture(scope="module") +def sample_pdb() -> str: + path = INPUT_DIR / "3F3E.pdb" + if not path.exists(): + pytest.skip("Sample PDB not found. Run scripts/download_structures.py first.") + return str(path) + + +@pytest.fixture(scope="module") +def structure(sample_pdb): + return load_structure(sample_pdb, pdb_id="3F3E") + + +def test_cavity_features(structure): + features = extract_cavity_features(structure) + assert "cavity_volume" in features + assert "cavity_accessibility_in" in features + assert "cavity_accessibility_out" in features + assert features["cavity_volume"] > 0 + + +def test_domain_features(structure): + features = extract_domain_features(structure) + assert "domain_TM1_TM7_distance" in features + assert features["domain_TM1_TM7_distance"] > 0 + assert 0 <= features["domain_TM1_TM7_angle"] <= 180 + + +def test_orientation_features(structure): + features = extract_orientation_features(structure) + assert "opm_tilt_angle" in features + assert "opm_rotation_angle" in features + assert "opm_depth" in features + + +def test_rmsd_features(sample_pdb, structure): + # Self-RMSD should be near zero when reference is available. + if not (INPUT_DIR / "3F3A.pdb").exists(): + pytest.skip("Reference PDB 3F3A not downloaded") + features = extract_rmsd_features(structure, reference_dir=str(INPUT_DIR)) + assert "rmsd_OF_open" in features + assert features["rmsd_OF_open"] < 1.0 # 3F3E vs itself + assert features["rmsd_min"] >= 0 + + +def test_extract_features(sample_pdb): + annotations = load_annotations(str(ANNOTATIONS)) + row = annotations[annotations["pdb_id"] == "3F3E"].iloc[0].to_dict() + features = extract_features(sample_pdb, annotations_row=row, reference_dir=str(INPUT_DIR)) + assert "cavity_volume" in features + assert "domain_TM1_TM7_distance" in features + assert "opm_tilt_angle" in features + # OPM columns are N/A in CSV, so tilt must be computed from coordinates. + assert features["opm_tilt_angle"] > 0 + + +def test_extract_features_batch(sample_pdb): + df = extract_features_batch([sample_pdb], reference_dir=str(INPUT_DIR)) + assert len(df) == 1 + assert "cavity_volume" in df.columns + assert df.iloc[0]["pdb_id"] == "3F3E" From bce9d83a310e6dcdec82a0646522ced8102e3667 Mon Sep 17 00:00:00 2001 From: Oliver Beckstein Date: Mon, 27 Jul 2026 17:14:18 -0700 Subject: [PATCH 12/18] more Plan notes on linting --- Plans/ci-setup-plan.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Plans/ci-setup-plan.md b/Plans/ci-setup-plan.md index ac28206..1c5f696 100644 --- a/Plans/ci-setup-plan.md +++ b/Plans/ci-setup-plan.md @@ -61,6 +61,11 @@ ruff format --check . cd docs && make html ``` +**PR CI note:** GitHub Actions on pull requests checks out a *merge* of the PR +branch into the base (`main`). Lint therefore sees files that exist on `main` +even if they are absent from the PR branch tip alone. Keep `ci` up to date with +`main` (or run ruff on the merge tree) before relying on a local green check. + Verified 2026-07-27: 3 tests passed; Sphinx HTML build succeeded. ## Follow-ups (manual / later) From 72a83178b2a65f86d9d8c2a560969bce98b46fc7 Mon Sep 17 00:00:00 2001 From: Oliver Beckstein Date: Mon, 27 Jul 2026 17:14:57 -0700 Subject: [PATCH 13/18] re-formatted code from main --- confostate/features/__init__.py | 2 +- confostate/features/_structure.py | 10 +++++--- confostate/features/cavity.py | 28 ++++++++++++++++++--- confostate/features/domains.py | 12 ++++++--- confostate/features/orientation.py | 39 ++++++++++++++++++++++++------ confostate/features/pipeline.py | 21 ++++++++++++---- confostate/features/rmsd.py | 22 +++++++++++------ scripts/extract_feature_vectors.py | 12 ++++++--- tests/test_features.py | 13 +++++++--- 9 files changed, 122 insertions(+), 37 deletions(-) diff --git a/confostate/features/__init__.py b/confostate/features/__init__.py index f370720..4755660 100644 --- a/confostate/features/__init__.py +++ b/confostate/features/__init__.py @@ -1,4 +1,4 @@ -"""Feature extraction for membrane protein conformational state classification.""" +"""Feature extraction for conformational state classification.""" from confostate.features.pipeline import ( FEATURE_GROUPS, diff --git a/confostate/features/_structure.py b/confostate/features/_structure.py index a0a3e24..2c05d48 100644 --- a/confostate/features/_structure.py +++ b/confostate/features/_structure.py @@ -34,7 +34,9 @@ def primary_chain_id(self) -> str: def ca_atoms(self) -> AtomGroup: """Alpha-carbon atoms for the primary chain.""" chain = self.primary_chain_id - ag = self.universe.select_atoms(f"protein and chainID {chain} and name CA") + ag = self.universe.select_atoms( + f"protein and chainID {chain} and name CA" + ) if len(ag) == 0: ag = self.universe.select_atoms(f"segid {chain} and name CA") return ag @@ -52,9 +54,11 @@ def select_residues( resid_str = " ".join(str(r) for r in resids) chain = self.primary_chain_id if heavy_atoms: - return self.universe.select_atoms( - f"protein and chainID {chain} and resid {resid_str} and not name H*" + sel = ( + f"protein and chainID {chain} " + f"and resid {resid_str} and not name H*" ) + return self.universe.select_atoms(sel) return self.ca_atoms.select_atoms(f"resid {resid_str}") diff --git a/confostate/features/cavity.py b/confostate/features/cavity.py index 558732a..5640013 100644 --- a/confostate/features/cavity.py +++ b/confostate/features/cavity.py @@ -9,9 +9,25 @@ from confostate.features._structure import StructureData, center_of_mass -# LeuT substrate / Na1 binding pocket residues (Yamashita et al. 2005, Singh et al. 2008). +# LeuT substrate / Na1 binding pocket residues +# (Yamashita et al. 2005, Singh et al. 2008). LEUT_BINDING_SITE_RESIDUES = ( - 21, 22, 23, 24, 55, 58, 91, 93, 108, 152, 156, 158, 256, 259, 319, 322, + 21, + 22, + 23, + 24, + 55, + 58, + 91, + 93, + 108, + 152, + 156, + 158, + 256, + 259, + 319, + 322, ) ACCESSIBILITY_RADIUS = 12.0 @@ -39,7 +55,9 @@ def _accessibility_along_axis( ca_coords = structure.ca_atoms.positions relative = ca_coords - site_center projections = relative @ normal - slab_mask = (projections * direction > 0) & (np.abs(projections) < ACCESSIBILITY_SLAB_HEIGHT) + slab_mask = (projections * direction > 0) & ( + np.abs(projections) < ACCESSIBILITY_SLAB_HEIGHT + ) if not slab_mask.any(): return 0.0 @@ -64,7 +82,9 @@ def extract_cavity_features( if membrane_normal is None: membrane_normal = np.array([0.0, 0.0, 1.0]) - binding_atoms = structure.select_residues(binding_residues, heavy_atoms=True) + binding_atoms = structure.select_residues( + binding_residues, heavy_atoms=True + ) if len(binding_atoms) == 0: binding_atoms = structure.select_residues(binding_residues) diff --git a/confostate/features/domains.py b/confostate/features/domains.py index efe3948..02edd8e 100644 --- a/confostate/features/domains.py +++ b/confostate/features/domains.py @@ -65,11 +65,17 @@ def extract_domain_features( features[f"{key_base}_distance"] = float("nan") features[f"{key_base}_angle"] = float("nan") continue - features[f"{key_base}_distance"] = pairwise_distance(coms[helix_a], coms[helix_b]) - features[f"{key_base}_angle"] = angle_between_vectors(axes[helix_a], axes[helix_b]) + features[f"{key_base}_distance"] = pairwise_distance( + coms[helix_a], coms[helix_b] + ) + features[f"{key_base}_angle"] = angle_between_vectors( + axes[helix_a], axes[helix_b] + ) if "TM1" in coms and "TM6" in coms: - features["domain_gate_TM1_TM6_distance"] = pairwise_distance(coms["TM1"], coms["TM6"]) + features["domain_gate_TM1_TM6_distance"] = pairwise_distance( + coms["TM1"], coms["TM6"] + ) else: features["domain_gate_TM1_TM6_distance"] = float("nan") diff --git a/confostate/features/orientation.py b/confostate/features/orientation.py index 925b2f7..5a4fed7 100644 --- a/confostate/features/orientation.py +++ b/confostate/features/orientation.py @@ -6,11 +6,17 @@ import numpy as np -from confostate.features._structure import StructureData, angle_between_vectors, principal_axis +from confostate.features._structure import ( + StructureData, + angle_between_vectors, + principal_axis, +) DEFAULT_MEMBRANE_NORMAL = np.array([0.0, 0.0, 1.0]) -_MISSING_VALUES = frozenset({"", "na", "n/a", "none", "null", "nan", "not_fetched", "pending"}) +_MISSING_VALUES = frozenset( + {"", "na", "n/a", "none", "null", "nan", "not_fetched", "pending"} +) def _is_missing(value: Any) -> bool: @@ -30,11 +36,15 @@ def _maybe_float(value: Any) -> Optional[float]: return None -def _tilt_angle(protein_axis: np.ndarray, membrane_normal: np.ndarray) -> float: +def _tilt_angle( + protein_axis: np.ndarray, membrane_normal: np.ndarray +) -> float: return angle_between_vectors(protein_axis, membrane_normal) -def _rotation_angle(protein_axis: np.ndarray, membrane_normal: np.ndarray) -> float: +def _rotation_angle( + protein_axis: np.ndarray, membrane_normal: np.ndarray +) -> float: normal = membrane_normal / np.linalg.norm(membrane_normal) projected = protein_axis - np.dot(protein_axis, normal) * normal proj_norm = np.linalg.norm(projected) @@ -58,7 +68,9 @@ def _rotation_angle(protein_axis: np.ndarray, membrane_normal: np.ndarray) -> fl return angle -def _membrane_depth(structure: StructureData, membrane_normal: np.ndarray) -> float: +def _membrane_depth( + structure: StructureData, membrane_normal: np.ndarray +) -> float: normal = membrane_normal / np.linalg.norm(membrane_normal) centroid = structure.ca_atoms.center_of_mass() return float(abs(np.dot(centroid, normal))) @@ -75,13 +87,22 @@ def extract_orientation_features( OPM columns from annotations are used when present; otherwise values are computed from MDAnalysis CA coordinates. """ - normal = membrane_normal if membrane_normal is not None else DEFAULT_MEMBRANE_NORMAL.copy() + normal = ( + membrane_normal + if membrane_normal is not None + else DEFAULT_MEMBRANE_NORMAL.copy() + ) axis = principal_axis(structure.ca_atoms.positions) features: dict[str, float] = {} if annotations_row: - for col in ("opm_tilt_angle", "opm_rotation_angle", "opm_depth", "opm_tm_count"): + for col in ( + "opm_tilt_angle", + "opm_rotation_angle", + "opm_depth", + "opm_tm_count", + ): parsed = _maybe_float(annotations_row.get(col)) if parsed is not None: features[col] = parsed @@ -100,6 +121,8 @@ def extract_orientation_features( return features -def get_membrane_normal(annotations_row: Optional[dict[str, Any]] = None) -> np.ndarray: +def get_membrane_normal( + annotations_row: Optional[dict[str, Any]] = None, +) -> np.ndarray: """Return membrane normal vector, defaulting to Z-axis.""" return DEFAULT_MEMBRANE_NORMAL.copy() diff --git a/confostate/features/pipeline.py b/confostate/features/pipeline.py index c311e35..ff56ce6 100644 --- a/confostate/features/pipeline.py +++ b/confostate/features/pipeline.py @@ -10,7 +10,10 @@ from confostate.features._structure import load_structure from confostate.features.cavity import extract_cavity_features from confostate.features.domains import extract_domain_features -from confostate.features.orientation import extract_orientation_features, get_membrane_normal +from confostate.features.orientation import ( + extract_orientation_features, + get_membrane_normal, +) from confostate.features.rmsd import extract_rmsd_features FEATURE_GROUPS = ("cavity", "domains", "rmsd", "orientation") @@ -34,9 +37,15 @@ def extract_features( features: dict[str, float] = {} - features.update(extract_cavity_features(structure, membrane_normal=membrane_normal)) + features.update( + extract_cavity_features(structure, membrane_normal=membrane_normal) + ) features.update(extract_domain_features(structure)) - features.update(extract_orientation_features(structure, annotations_row=annotations_row)) + features.update( + extract_orientation_features( + structure, annotations_row=annotations_row + ) + ) if include_rmsd: try: @@ -57,13 +66,15 @@ def extract_features_batch( annotations_df: Optional[pd.DataFrame] = None, reference_dir: Optional[str] = None, ) -> pd.DataFrame: - """Extract features for multiple PDB files; returns one row per structure.""" + """Extract features for multiple PDB files (one row per structure).""" rows = [] for pdb_path in pdb_paths: pdb_id = Path(pdb_path).stem.upper() row_data: Optional[dict[str, Any]] = None if annotations_df is not None and "pdb_id" in annotations_df.columns: - matches = annotations_df[annotations_df["pdb_id"].str.upper() == pdb_id] + matches = annotations_df[ + annotations_df["pdb_id"].str.upper() == pdb_id + ] if len(matches) > 0: row_data = matches.iloc[0].to_dict() diff --git a/confostate/features/rmsd.py b/confostate/features/rmsd.py index e4a08a4..63cc059 100644 --- a/confostate/features/rmsd.py +++ b/confostate/features/rmsd.py @@ -51,10 +51,12 @@ def _resolve_reference_path(pdb_id: str, reference_dir: Optional[str]) -> Path: candidates = [] if reference_dir: candidates.append(Path(reference_dir) / f"{pdb_id}.pdb") - candidates.extend([ - Path("input") / f"{pdb_id}.pdb", - Path("data/structures") / f"{pdb_id}.pdb", - ]) + candidates.extend( + [ + Path("input") / f"{pdb_id}.pdb", + Path("data/structures") / f"{pdb_id}.pdb", + ] + ) for path in candidates: if path.exists(): return path @@ -82,15 +84,21 @@ def extract_rmsd_features( for state, ref_pdb_id in refs.items(): ref_path = _resolve_reference_path(ref_pdb_id, reference_dir) reference = load_structure(str(ref_path), pdb_id=ref_pdb_id) - mobile_coords, ref_coords = _aligned_ca_groups(structure, reference, alignment_residues) + mobile_coords, ref_coords = _aligned_ca_groups( + structure, reference, alignment_residues + ) rmsd_val = rms.rmsd(mobile_coords, ref_coords, superposition=True) features[f"rmsd_{state}"] = float(rmsd_val) rmsd_values.append(float(rmsd_val)) - features["rmsd_min"] = float(min(rmsd_values)) if rmsd_values else float("nan") + features["rmsd_min"] = ( + float(min(rmsd_values)) if rmsd_values else float("nan") + ) if rmsd_values: best_state = min(refs.keys(), key=lambda s: features[f"rmsd_{s}"]) - features["rmsd_best_state_index"] = float(list(refs.keys()).index(best_state)) + features["rmsd_best_state_index"] = float( + list(refs.keys()).index(best_state) + ) else: features["rmsd_best_state_index"] = float("nan") diff --git a/scripts/extract_feature_vectors.py b/scripts/extract_feature_vectors.py index 09e1c8f..aaf48c3 100644 --- a/scripts/extract_feature_vectors.py +++ b/scripts/extract_feature_vectors.py @@ -16,7 +16,9 @@ def main() -> None: - parser = argparse.ArgumentParser(description="Extract feature vectors for LeuT structures.") + parser = argparse.ArgumentParser( + description="Extract feature vectors for LeuT structures." + ) parser.add_argument( "--annotations", default=DEFAULT_ANNOTATIONS, @@ -47,7 +49,9 @@ def main() -> None: merged = annotations.merge(structures, on="pdb_id", how="inner") if len(merged) == 0: - raise SystemExit("No overlap between annotations and downloaded PDB files.") + raise SystemExit( + "No overlap between annotations and downloaded PDB files." + ) print(f"Extracting features for {len(merged)} structures...") df = extract_features_batch( @@ -60,7 +64,9 @@ def main() -> None: output_path.parent.mkdir(parents=True, exist_ok=True) df.to_csv(output_path, index=False) print(f"Wrote {len(df)} feature vectors to {output_path}") - print(f"Feature columns: {len([c for c in df.columns if c not in ('pdb_id', 'file_path', 'conformation')])}") + meta = ("pdb_id", "file_path", "conformation") + n_feat = len([c for c in df.columns if c not in meta]) + print(f"Feature columns: {n_feat}") if __name__ == "__main__": diff --git a/tests/test_features.py b/tests/test_features.py index ab6be0d..f8cf041 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -13,14 +13,19 @@ from confostate.features.rmsd import extract_rmsd_features INPUT_DIR = Path(__file__).resolve().parent.parent / "input" -ANNOTATIONS = Path(__file__).resolve().parent.parent / "data/annotations/leu_t_transporters.csv" +ANNOTATIONS = ( + Path(__file__).resolve().parent.parent + / "data/annotations/leu_t_transporters.csv" +) @pytest.fixture(scope="module") def sample_pdb() -> str: path = INPUT_DIR / "3F3E.pdb" if not path.exists(): - pytest.skip("Sample PDB not found. Run scripts/download_structures.py first.") + pytest.skip( + "Sample PDB not found. Run scripts/download_structures.py first." + ) return str(path) @@ -64,7 +69,9 @@ def test_rmsd_features(sample_pdb, structure): def test_extract_features(sample_pdb): annotations = load_annotations(str(ANNOTATIONS)) row = annotations[annotations["pdb_id"] == "3F3E"].iloc[0].to_dict() - features = extract_features(sample_pdb, annotations_row=row, reference_dir=str(INPUT_DIR)) + features = extract_features( + sample_pdb, annotations_row=row, reference_dir=str(INPUT_DIR) + ) assert "cavity_volume" in features assert "domain_TM1_TM7_distance" in features assert "opm_tilt_angle" in features From 3afbdd07f51a11a52ba59ecd3175686cd8efef9c Mon Sep 17 00:00:00 2001 From: Oliver Beckstein Date: Mon, 27 Jul 2026 17:17:32 -0700 Subject: [PATCH 14/18] ignore reformatting commits --- .git-blame-ignore-revs | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..a5fa8e9 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,8 @@ +# Commits to ignore in git blame / GitHub blame (pure reformatting only). +# https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view + +# ruff reformatted py files to follow coding style +14d6f8282db6ceb7b512b4c2ab88584d22ad0932 + +# re-formatted code from main +8b0662d77a285ac4f8a464c03f44d463ad17b25d From 448ae9f369138b05aeae8dc49637102e208ecee6 Mon Sep 17 00:00:00 2001 From: Oliver Beckstein Date: Mon, 27 Jul 2026 17:50:33 -0700 Subject: [PATCH 15/18] reorganized docs and fixed build --- docs/conf.py | 2 ++ docs/{ => examples}/LeuT_descriptors.md | 2 ++ docs/{ => features}/features.md | 3 ++- docs/index.md | 6 ++++-- 4 files changed, 10 insertions(+), 3 deletions(-) rename docs/{ => examples}/LeuT_descriptors.md (99%) rename docs/{ => features}/features.md (98%) diff --git a/docs/conf.py b/docs/conf.py index b2c066b..94b62fa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,6 +33,8 @@ "colon_fence", "deflist", ] +# Generate reference targets for H1/H2 so Markdown `#fragment` links resolve. +myst_heading_anchors = 2 source_suffix = { ".rst": "restructuredtext", diff --git a/docs/LeuT_descriptors.md b/docs/examples/LeuT_descriptors.md similarity index 99% rename from docs/LeuT_descriptors.md rename to docs/examples/LeuT_descriptors.md index 5f6cc57..dc7d31a 100644 --- a/docs/LeuT_descriptors.md +++ b/docs/examples/LeuT_descriptors.md @@ -1,3 +1,5 @@ +# LeuT descriptors + The LeuT family of transporter proteins, also known as the leucine transporter family, is a group of membrane proteins that play a crucial role in the transport of amino acids across cell membranes. These transporters are responsible for the uptake of essential amino acids, such as leucine, isoleucine, and valine, into cells. ## Macromolecular Conformations in the LeuT Family diff --git a/docs/features.md b/docs/features/features.md similarity index 98% rename from docs/features.md rename to docs/features/features.md index 5b4b091..bd7cdd7 100644 --- a/docs/features.md +++ b/docs/features/features.md @@ -45,6 +45,7 @@ python scripts/extract_feature_vectors.py Output: `data/features/leu_t_feature_vectors.csv` (generated locally; gitignored) +(data-provenance)= ## Data provenance & current status (Person 3) **Read this before training.** Not all columns in the feature table are equally trustworthy. @@ -187,7 +188,7 @@ available; otherwise estimates tilt/rotation from the structure principal axis. ## Dependencies on Person 1 (data) Person 1 will deliver a verified `data/annotations/leu_t_transporters.csv`. -Until then, use the local stub described in [Data provenance & current status](#data-provenance--current-status-person-3). +Until then, use the local stub described in {ref}`Data provenance & current status `. - **Labels:** `conformation` (+ `conformation_status` column when verified) - **Metadata:** resolution, method, year from RCSB API diff --git a/docs/index.md b/docs/index.md index 31af351..5e74051 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,10 +8,12 @@ described in the literature. :caption: Contents USAGE -api development scripts/download_structures -LeuT_descriptors +scripts/extract_feature_vectors +examples/LeuT_descriptors +features/features.md +api ``` ## Quick start From 02dad9f37ffb0ae4a1aa0073e4e50b02facd2cd3 Mon Sep 17 00:00:00 2001 From: Oliver Beckstein Date: Mon, 27 Jul 2026 17:50:57 -0700 Subject: [PATCH 16/18] add cursor rule: use confostate mamba env --- .cursor/rules/confostate-env.mdc | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .cursor/rules/confostate-env.mdc diff --git a/.cursor/rules/confostate-env.mdc b/.cursor/rules/confostate-env.mdc new file mode 100644 index 0000000..397f44e --- /dev/null +++ b/.cursor/rules/confostate-env.mdc @@ -0,0 +1,18 @@ +--- +description: Use the confostate mamba environment for all shell work +alwaysApply: true +--- + +# ConfoState environment + +Always use the `confostate` mamba/conda env for Python, Sphinx, pytest, and other project tooling. + +```bash +# Activate (interactive shells) +mamba activate confostate + +# Non-interactive / agent shells: put the env on PATH +export PATH="/Users/oliver/miniforge3/envs/confostate/bin:$PATH" +``` + +Do not rely on system Python (`/opt/local/bin/python`, bare `python3`) or an empty `.venv`. From 7f356b4bcf7de5ec02dd446a40376ec9ed4b512e Mon Sep 17 00:00:00 2001 From: lrepa Date: Mon, 10 Aug 2026 09:36:20 -0700 Subject: [PATCH 17/18] Cursor draft, woo --- Plans/person4-interpretability-plan.md | 58 +++++ confostate/explain/__init__.py | 145 ++++++++++++ confostate/explain/_types.py | 66 ++++++ confostate/explain/citations.py | 148 +++++++++++++ confostate/explain/importance.py | 292 +++++++++++++++++++++++++ confostate/explain/render.py | 120 ++++++++++ docs/explainability.md | 140 ++++++++++++ docs/index.md | 1 + explain/importance.py | 16 -- explain/render.py | 0 tests/test_explain.py | 186 ++++++++++++++++ 11 files changed, 1156 insertions(+), 16 deletions(-) create mode 100644 Plans/person4-interpretability-plan.md create mode 100644 confostate/explain/__init__.py create mode 100644 confostate/explain/_types.py create mode 100644 confostate/explain/citations.py create mode 100644 confostate/explain/importance.py create mode 100644 confostate/explain/render.py create mode 100644 docs/explainability.md delete mode 100644 explain/importance.py delete mode 100644 explain/render.py create mode 100644 tests/test_explain.py diff --git a/Plans/person4-interpretability-plan.md b/Plans/person4-interpretability-plan.md new file mode 100644 index 0000000..3459b95 --- /dev/null +++ b/Plans/person4-interpretability-plan.md @@ -0,0 +1,58 @@ +# Person 4: Explainability & Interpretation Plan + +**Date:** 2026-08-10 +**Branch:** `person4-interpretability` +**Assignees:** Leah, Apollo +**Status:** In progress + +## Goal + +Build the explainability layer (`confostate/explain/`) that turns model +predictions and feature vectors into human-readable justifications with +literature citations. + +## Approach + +1. **Package location:** Move from root `explain/` stub into + `confostate/explain/` to match the workplan and package layout. +2. **Feature importance:** Support three paths (in order of preference): + - Explicit importances passed by the caller (from Person 3's pipeline) + - Permutation importance for sklearn-compatible models with `predict_proba` + - Heuristic importance from feature values vs. LeuT state profiles (stub + path while models are unavailable) +3. **Rendering:** Template-based natural language (no LLM dependency for v1). + Keeps explanations deterministic, testable, and free of GPU/API cost. +4. **Citations:** Curated DOI/PubMed mappings for LeuT states and key + structural features, pulled from the annotations reference column. +5. **Public API:** `explain(prediction) -> ExplanationResult` with text and + markdown output formats. + +## Key decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| LLM rendering | Deferred | Workplan mentions Llama/ASU tokens; template v1 unblocks Person 5 CLI | +| SHAP | Deferred | Heavy dependency; permutation + heuristics sufficient for v1 | +| sklearn | Optional import | Used when available; heuristic fallback keeps package lightweight | +| Synthetic data | Heuristic profiles | Workplan: "make up sh**t to move forward" until Person 3 delivers models | + +## Deliverables + +- [x] `confostate/explain/importance.py` +- [x] `confostate/explain/render.py` +- [x] `confostate/explain/citations.py` +- [x] `confostate/explain/__init__.py` with `explain()` +- [x] `tests/test_explain.py` +- [x] `docs/explainability.md` + +## Dependencies + +- **Person 2:** Feature names and semantics (`docs/features/features.md`) +- **Person 3:** Trained models and real permutation importances (future) +- **Blocks:** Person 5 CLI integration + +## Future work + +- SHAP values for tree/neural models +- LLM polish pass (local Llama at ASU) +- Multi-family citation and profile tables diff --git a/confostate/explain/__init__.py b/confostate/explain/__init__.py new file mode 100644 index 0000000..4e70b87 --- /dev/null +++ b/confostate/explain/__init__.py @@ -0,0 +1,145 @@ +"""Explainability layer for conformational-state predictions.""" + +from __future__ import annotations + +from typing import Any, Literal, Optional, Sequence, Union, overload + +from confostate.explain._types import ( + Citation, + ExplanationResult, + FeatureImportance, + PredictionResult, +) +from confostate.explain.citations import get_citations, state_label +from confostate.explain.importance import ( + FEATURE_DISPLAY_NAMES, + display_name, + rank_importances, + resolve_importances, +) +from confostate.explain.render import render_markdown, render_text + +__all__ = [ + "Citation", + "ExplanationResult", + "FeatureImportance", + "PredictionResult", + "FEATURE_DISPLAY_NAMES", + "display_name", + "explain", + "state_label", +] + + +@overload +def explain( + prediction: Union[PredictionResult, dict[str, Any]], + *, + top_k: int = 5, + importances: Optional[Sequence[FeatureImportance]] = None, + model: Optional[Any] = None, + feature_matrix: Optional[Any] = None, + feature_names: Optional[Sequence[str]] = None, + coefficients: Optional[dict[str, float]] = None, + output_format: Literal["result"] = "result", +) -> ExplanationResult: ... + + +@overload +def explain( + prediction: Union[PredictionResult, dict[str, Any]], + *, + top_k: int = 5, + importances: Optional[Sequence[FeatureImportance]] = None, + model: Optional[Any] = None, + feature_matrix: Optional[Any] = None, + feature_names: Optional[Sequence[str]] = None, + coefficients: Optional[dict[str, float]] = None, + output_format: Literal["text", "markdown"], +) -> str: ... + + +def explain( + prediction: Union[PredictionResult, dict[str, Any]], + *, + top_k: int = 5, + importances: Optional[Sequence[FeatureImportance]] = None, + model: Optional[Any] = None, + feature_matrix: Optional[Any] = None, + feature_names: Optional[Sequence[str]] = None, + coefficients: Optional[dict[str, float]] = None, + output_format: Literal["result", "text", "markdown"] = "result", +) -> Union[ExplanationResult, str]: + """ + Generate a human-readable explanation for a classifier prediction. + + Parameters + ---------- + prediction + PredictionResult or dict with pdb_id, predicted_state, + probabilities, and features. + top_k + Number of top features to include in the explanation. + importances + Pre-computed feature importances (from Person 3's pipeline). + model + Optional sklearn-compatible model for permutation importance. + feature_matrix + Feature matrix for permutation importance (single row or batch). + feature_names + Feature names aligned with feature_matrix columns. + coefficients + Linear-model coefficients keyed by feature name. + output_format + ``"result"`` returns ExplanationResult; ``"text"`` or + ``"markdown"`` returns rendered strings. + + Returns + ------- + ExplanationResult or str + Full explanation object or rendered text. + """ + if not isinstance(prediction, PredictionResult): + prediction = PredictionResult.from_dict(prediction) + + resolved, method = resolve_importances( + features=prediction.features, + predicted_state=prediction.predicted_state, + family=prediction.family, + importances=importances, + model=model, + feature_matrix=feature_matrix, + feature_names=feature_names, + coefficients=coefficients, + ) + top_features = rank_importances(resolved, top_k=top_k) + citations = get_citations( + predicted_state=prediction.predicted_state, + feature_names=[item.feature_name for item in top_features], + family=prediction.family, + ) + + confidence = prediction.probabilities.get( + prediction.predicted_state, 0.0 + ) + text = render_text(prediction, top_features, citations, method) + markdown = render_markdown( + prediction, top_features, citations, method + ) + + result = ExplanationResult( + pdb_id=prediction.pdb_id, + predicted_state=prediction.predicted_state, + confidence=confidence, + text=text, + markdown=markdown, + top_features=top_features, + citations=citations, + method=method, + ) + + if output_format == "text": + return result.text + if output_format == "markdown": + return result.markdown + return result diff --git a/confostate/explain/_types.py b/confostate/explain/_types.py new file mode 100644 index 0000000..ebf2822 --- /dev/null +++ b/confostate/explain/_types.py @@ -0,0 +1,66 @@ +"""Shared types for the explainability layer.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional + + +@dataclass +class PredictionResult: + """Output of a conformational-state classifier.""" + + pdb_id: str + predicted_state: str + probabilities: dict[str, float] + features: dict[str, float] + family: str = "LeuT" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PredictionResult: + """Build a prediction from a plain dict.""" + return cls( + pdb_id=str(data["pdb_id"]), + predicted_state=str(data["predicted_state"]), + probabilities={ + str(k): float(v) for k, v in data["probabilities"].items() + }, + features={str(k): float(v) for k, v in data["features"].items()}, + family=str(data.get("family", "LeuT")), + ) + + +@dataclass +class FeatureImportance: + """Importance of a single feature for a prediction.""" + + feature_name: str + display_name: str + importance: float + value: float + direction: str # "supports" or "opposes" + + +@dataclass +class Citation: + """Literature reference for a state or feature.""" + + key: str + label: str + reference: str + doi: Optional[str] = None + pubmed_id: Optional[str] = None + + +@dataclass +class ExplanationResult: + """Full explanation for a single prediction.""" + + pdb_id: str + predicted_state: str + confidence: float + text: str + markdown: str + top_features: list[FeatureImportance] = field(default_factory=list) + citations: list[Citation] = field(default_factory=list) + method: str = "heuristic" diff --git a/confostate/explain/citations.py b/confostate/explain/citations.py new file mode 100644 index 0000000..2eb03f9 --- /dev/null +++ b/confostate/explain/citations.py @@ -0,0 +1,148 @@ +"""Literature citations for explainability output.""" + +from __future__ import annotations + +from typing import Sequence + +from confostate.explain._types import Citation + +# Curated references for LeuT conformational states and structural motifs. +LEUT_STATE_CITATIONS: dict[str, Citation] = { + "IF_open": Citation( + key="IF_open", + label="Inward-facing open", + reference="Yamashita et al., Nature 2005", + doi="10.1038/nature03578", + pubmed_id="15880103", + ), + "OF_open": Citation( + key="OF_open", + label="Outward-facing open", + reference="Singh et al., Science 2008", + doi="10.1126/science.1159299", + pubmed_id="18403708", + ), + "Occluded": Citation( + key="Occluded", + label="Occluded", + reference="Shi et al., Nature 2008", + doi="10.1038/nature06932", + pubmed_id="18497808", + ), + "Intermediate": Citation( + key="Intermediate", + label="Intermediate", + reference="Claxton et al., J. Mol. Biol. 2010", + doi="10.1016/j.jmb.2010.09.022", + pubmed_id="20869387", + ), +} + +LEUT_FEATURE_CITATIONS: dict[str, Citation] = { + "domain_TM1_TM7_distance": Citation( + key="domain_TM1_TM7_distance", + label="TM1–TM7 gate distance", + reference="Singh et al., Science 2008 (outward-open gate)", + doi="10.1126/science.1159299", + pubmed_id="18403708", + ), + "domain_gate_TM1_TM6_distance": Citation( + key="domain_gate_TM1_TM6_distance", + label="TM1–TM6 gate opening", + reference="Yamashita et al., Nature 2005 (inward-open gate)", + doi="10.1038/nature03578", + pubmed_id="15880103", + ), + "cavity_volume": Citation( + key="cavity_volume", + label="Binding-site cavity", + reference="Yamashita et al., Nature 2005", + doi="10.1038/nature03578", + pubmed_id="15880103", + ), + "rmsd_IF_open": Citation( + key="rmsd_IF_open", + label="RMSD to inward-open reference", + reference="Yamashita et al., Nature 2005 (3F3A)", + doi="10.1038/nature03578", + pubmed_id="15880103", + ), + "rmsd_OF_open": Citation( + key="rmsd_OF_open", + label="RMSD to outward-open reference", + reference="Singh et al., Science 2008 (3F3E)", + doi="10.1126/science.1159299", + pubmed_id="18403708", + ), + "rmsd_Occluded": Citation( + key="rmsd_Occluded", + label="RMSD to occluded reference", + reference="Shi et al., Nature 2008 (3F4J)", + doi="10.1038/nature06932", + pubmed_id="18497808", + ), + "rmsd_Intermediate": Citation( + key="rmsd_Intermediate", + label="RMSD to intermediate reference", + reference="Claxton et al., J. Mol. Biol. 2010 (3USI)", + doi="10.1016/j.jmb.2010.09.022", + pubmed_id="20869387", + ), +} + +STATE_LABELS: dict[str, str] = { + "IF_open": "inward-facing open", + "OF_open": "outward-facing open", + "Occluded": "occluded", + "Intermediate": "intermediate", +} + + +def state_label(state: str) -> str: + """Return a human-readable state name.""" + return STATE_LABELS.get(state, state.replace("_", " ")) + + +def get_citations( + predicted_state: str, + feature_names: Sequence[str], + family: str = "LeuT", +) -> list[Citation]: + """ + Collect literature citations for a predicted state and its top features. + + Deduplicates by DOI when the same reference covers multiple items. + """ + if family != "LeuT": + return [] + + seen_dois: set[str] = set() + citations: list[Citation] = [] + + state_citation = LEUT_STATE_CITATIONS.get(predicted_state) + if state_citation is not None: + citations.append(state_citation) + if state_citation.doi: + seen_dois.add(state_citation.doi) + + for name in feature_names: + feature_citation = LEUT_FEATURE_CITATIONS.get(name) + if feature_citation is None: + continue + if feature_citation.doi and feature_citation.doi in seen_dois: + continue + citations.append(feature_citation) + if feature_citation.doi: + seen_dois.add(feature_citation.doi) + + return citations + + +def format_citation(citation: Citation) -> str: + """Format a single citation for plain-text output.""" + parts = [citation.reference] + if citation.doi: + parts.append(f"DOI: {citation.doi}") + if citation.pubmed_id: + parts.append(f"PubMed: {citation.pubmed_id}") + return " — ".join(parts) diff --git a/confostate/explain/importance.py b/confostate/explain/importance.py new file mode 100644 index 0000000..e706879 --- /dev/null +++ b/confostate/explain/importance.py @@ -0,0 +1,292 @@ +"""Feature importance extraction for conformational-state predictions.""" + +from __future__ import annotations + +from typing import Any, Optional, Sequence + +import numpy as np + +from confostate.explain._types import FeatureImportance + +FEATURE_DISPLAY_NAMES: dict[str, str] = { + "cavity_volume": "binding-site cavity volume", + "cavity_accessibility_in": "inward solvent accessibility", + "cavity_accessibility_out": "outward solvent accessibility", + "domain_TM1_TM7_distance": "TM1–TM7 gate distance", + "domain_TM1_TM7_angle": "TM1–TM7 helix angle", + "domain_TM1_TM6_distance": "TM1–TM6 distance", + "domain_TM5_TM7_distance": "TM5–TM7 distance", + "domain_TM3_TM10_distance": "TM3–TM10 distance", + "domain_gate_TM1_TM6_distance": "TM1–TM6 gate-opening distance", + "rmsd_OF_open": "RMSD to outward-open reference (3F3E)", + "rmsd_IF_open": "RMSD to inward-open reference (3F3A)", + "rmsd_Occluded": "RMSD to occluded reference (3F4J)", + "rmsd_Intermediate": "RMSD to intermediate reference (3USI)", + "rmsd_min": "minimum RMSD across references", + "opm_tilt_angle": "membrane tilt angle", + "opm_rotation_angle": "membrane rotation angle", + "opm_depth": "membrane embedding depth", + "orientation_principal_axis_x": "principal axis (x)", + "orientation_principal_axis_y": "principal axis (y)", + "orientation_principal_axis_z": "principal axis (z)", +} + +# Typical feature values per LeuT state for heuristic importance (stub data). +# Values are approximate literature-informed placeholders until Person 3 +# provides real training statistics. +LEUT_STATE_PROFILES: dict[str, dict[str, float]] = { + "IF_open": { + "domain_TM1_TM7_distance": 18.0, + "domain_gate_TM1_TM6_distance": 22.0, + "rmsd_IF_open": 1.5, + "cavity_accessibility_in": 0.55, + "cavity_accessibility_out": 0.25, + }, + "OF_open": { + "domain_TM1_TM7_distance": 22.0, + "domain_gate_TM1_TM6_distance": 26.0, + "rmsd_OF_open": 1.5, + "cavity_accessibility_in": 0.25, + "cavity_accessibility_out": 0.55, + }, + "Occluded": { + "domain_TM1_TM7_distance": 16.0, + "domain_gate_TM1_TM6_distance": 18.0, + "rmsd_Occluded": 1.5, + "cavity_volume": 450.0, + }, + "Intermediate": { + "domain_TM1_TM7_distance": 19.0, + "domain_gate_TM1_TM6_distance": 20.0, + "rmsd_Intermediate": 1.5, + }, +} + +RMSD_STATE_MAP = { + "IF_open": "rmsd_IF_open", + "OF_open": "rmsd_OF_open", + "Occluded": "rmsd_Occluded", + "Intermediate": "rmsd_Intermediate", +} + + +def display_name(feature_name: str) -> str: + """Return a human-readable label for a feature.""" + return FEATURE_DISPLAY_NAMES.get( + feature_name, feature_name.replace("_", " ") + ) + + +def rank_importances( + importances: Sequence[FeatureImportance], top_k: int = 5 +) -> list[FeatureImportance]: + """Return the top-k features by absolute importance.""" + ranked = sorted( + importances, key=lambda item: abs(item.importance), reverse=True + ) + return ranked[:top_k] + + +def extract_coefficient_importance( + coefficients: dict[str, float], + feature_values: dict[str, float], + predicted_state: str, +) -> list[FeatureImportance]: + """ + Compute importance from linear-model coefficients. + + Importance is |coefficient * value| for the predicted state's class. + """ + results: list[FeatureImportance] = [] + for name, coef in coefficients.items(): + if name not in feature_values: + continue + value = feature_values[name] + score = coef * value + results.append( + FeatureImportance( + feature_name=name, + display_name=display_name(name), + importance=score, + value=value, + direction="supports" if score >= 0 else "opposes", + ) + ) + return results + + +def extract_permutation_importance( + model: Any, + feature_matrix: np.ndarray, + feature_names: Sequence[str], + baseline_proba: np.ndarray, + n_repeats: int = 5, + random_state: int = 0, +) -> list[FeatureImportance]: + """ + Model-agnostic permutation importance for a single prediction. + + Shuffles each feature column and measures the drop in predicted + probability for the baseline class. + """ + rng = np.random.default_rng(random_state) + baseline_score = float(np.max(baseline_proba)) + predicted_index = int(np.argmax(baseline_proba)) + row_idx = 0 + n_samples = feature_matrix.shape[0] + importances = np.zeros(len(feature_names)) + + for col_idx, name in enumerate(feature_names): + drops: list[float] = [] + original_value = float(feature_matrix[row_idx, col_idx]) + for _ in range(n_repeats): + permuted = feature_matrix.copy() + if n_samples > 1: + other_values = np.delete( + feature_matrix[:, col_idx], row_idx + ) + permuted[row_idx, col_idx] = rng.choice(other_values) + else: + scale = max(abs(original_value), 1.0) + permuted[row_idx, col_idx] = original_value + rng.normal( + 0, 0.2 * scale + ) + proba = model.predict_proba(permuted)[row_idx] + drops.append(baseline_score - float(proba[predicted_index])) + importances[col_idx] = float(np.mean(drops)) + + results: list[FeatureImportance] = [] + for name, score in zip(feature_names, importances): + value = float(feature_matrix[row_idx, list(feature_names).index(name)]) + results.append( + FeatureImportance( + feature_name=name, + display_name=display_name(name), + importance=score, + value=value, + direction="supports" if score >= 0 else "opposes", + ) + ) + return results + + +def extract_heuristic_importance( + features: dict[str, float], + predicted_state: str, + family: str = "LeuT", +) -> list[FeatureImportance]: + """ + Estimate feature importance without a trained model. + + Compares each numeric feature to family-specific state profiles. + Lower RMSD to the matching reference and closer agreement with the + predicted-state profile yield higher scores. + """ + if family != "LeuT": + return _generic_heuristic_importance(features, predicted_state) + + profile = LEUT_STATE_PROFILES.get(predicted_state, {}) + results: list[FeatureImportance] = [] + + for name, value in features.items(): + if not isinstance(value, (int, float)) or np.isnan(value): + continue + + score = 0.0 + direction = "supports" + + if name in profile: + expected = profile[name] + rel_error = abs(value - expected) / max(abs(expected), 1e-6) + score = max(0.0, 1.0 - rel_error) + direction = "supports" if score > 0.3 else "opposes" + elif name.startswith("rmsd_"): + if name == RMSD_STATE_MAP.get(predicted_state): + score = max(0.0, 3.0 - value) + direction = "supports" + else: + score = max(0.0, value - 2.0) * 0.3 + direction = "opposes" + elif name.startswith("domain_") or name.startswith("cavity_"): + score = abs(value) * 0.01 + direction = "supports" + else: + score = abs(value) * 0.001 + direction = "supports" + + results.append( + FeatureImportance( + feature_name=name, + display_name=display_name(name), + importance=score, + value=float(value), + direction=direction, + ) + ) + + return results + + +def _generic_heuristic_importance( + features: dict[str, float], predicted_state: str +) -> list[FeatureImportance]: + """Fallback heuristic when no family profile is available.""" + results: list[FeatureImportance] = [] + for name, value in features.items(): + if not isinstance(value, (int, float)) or np.isnan(value): + continue + score = abs(float(value)) * 0.01 + results.append( + FeatureImportance( + feature_name=name, + display_name=display_name(name), + importance=score, + value=float(value), + direction="supports", + ) + ) + return results + + +def resolve_importances( + features: dict[str, float], + predicted_state: str, + family: str = "LeuT", + importances: Optional[Sequence[FeatureImportance]] = None, + model: Optional[Any] = None, + feature_matrix: Optional[np.ndarray] = None, + feature_names: Optional[Sequence[str]] = None, + coefficients: Optional[dict[str, float]] = None, +) -> tuple[list[FeatureImportance], str]: + """ + Resolve feature importances using the best available method. + + Returns (importances, method_name). + """ + if importances is not None: + return list(importances), "provided" + + if coefficients is not None: + return ( + extract_coefficient_importance( + coefficients, features, predicted_state + ), + "coefficient", + ) + + if model is not None and feature_matrix is not None and feature_names: + baseline_proba = model.predict_proba(feature_matrix)[0] + return ( + extract_permutation_importance( + model, + feature_matrix, + feature_names, + baseline_proba, + ), + "permutation", + ) + + return ( + extract_heuristic_importance(features, predicted_state, family), + "heuristic", + ) diff --git a/confostate/explain/render.py b/confostate/explain/render.py new file mode 100644 index 0000000..2511135 --- /dev/null +++ b/confostate/explain/render.py @@ -0,0 +1,120 @@ +"""Template-based explanation rendering.""" + +from __future__ import annotations + +from typing import Sequence + +from confostate.explain._types import ( + Citation, + FeatureImportance, + PredictionResult, +) +from confostate.explain.citations import format_citation, state_label + + +def _format_value(value: float) -> str: + if abs(value) >= 100: + return f"{value:.1f}" + if abs(value) >= 10: + return f"{value:.2f}" + return f"{value:.3f}" + + +def _feature_sentence(item: FeatureImportance, predicted_state: str) -> str: + value_str = _format_value(item.value) + state_name = state_label(predicted_state) + verb = "supports" if item.direction == "supports" else "is atypical for" + return ( + f"The {item.display_name} ({value_str}) {verb} " + f"an {state_name} conformation." + ) + + +def render_text( + prediction: PredictionResult, + top_features: Sequence[FeatureImportance], + citations: Sequence[Citation], + method: str, +) -> str: + """Render a plain-text explanation.""" + confidence = prediction.probabilities.get( + prediction.predicted_state, 0.0 + ) + state_name = state_label(prediction.predicted_state) + lines = [ + ( + f"Structure {prediction.pdb_id} is predicted to be " + f"{state_name} ({confidence:.0%} confidence)." + ), + "", + "Key structural evidence:", + ] + + if top_features: + for item in top_features: + sentence = _feature_sentence(item, prediction.predicted_state) + lines.append(f"- {sentence}") + else: + lines.append("- No distinguishing features were identified.") + + lines.extend(["", f"Importance method: {method}."]) + + if citations: + lines.extend(["", "References:"]) + for citation in citations: + lines.append(f"- {format_citation(citation)}") + + return "\n".join(lines) + + +def render_markdown( + prediction: PredictionResult, + top_features: Sequence[FeatureImportance], + citations: Sequence[Citation], + method: str, +) -> str: + """Render a Markdown explanation.""" + confidence = prediction.probabilities.get( + prediction.predicted_state, 0.0 + ) + state_name = state_label(prediction.predicted_state) + lines = [ + f"## Prediction: {prediction.pdb_id}", + "", + ( + f"**State:** {state_name} " + f"({prediction.predicted_state}) \n" + f"**Confidence:** {confidence:.0%} \n" + f"**Family:** {prediction.family}" + ), + "", + "### Key structural evidence", + "", + ] + + if top_features: + for item in top_features: + lines.append( + f"- {_feature_sentence(item, prediction.predicted_state)}" + ) + else: + lines.append("- No distinguishing features were identified.") + + lines.extend(["", f"*Importance method: {method}*", ""]) + + if citations: + lines.extend(["### References", ""]) + for citation in citations: + ref = citation.reference + links: list[str] = [] + if citation.doi: + links.append(f"[DOI](https://doi.org/{citation.doi})") + if citation.pubmed_id: + links.append( + f"[PubMed](https://pubmed.ncbi.nlm.nih.gov/" + f"{citation.pubmed_id}/)" + ) + suffix = f" ({', '.join(links)})" if links else "" + lines.append(f"- {ref}{suffix}") + + return "\n".join(lines) diff --git a/docs/explainability.md b/docs/explainability.md new file mode 100644 index 0000000..eaca083 --- /dev/null +++ b/docs/explainability.md @@ -0,0 +1,140 @@ +# Explainability + +ConfoState generates human-readable explanations for conformational-state +predictions. The explainability layer sits downstream of feature extraction +(Person 2) and model inference (Person 3). + +## Package layout + +``` +confostate/explain/ +├── __init__.py # explain() public API +├── _types.py # PredictionResult, ExplanationResult, etc. +├── importance.py # Feature importance extraction +├── render.py # Template-based text/markdown rendering +└── citations.py # Literature references (DOI, PubMed) +``` + +## Quick start + +```python +from confostate.explain import explain, PredictionResult + +prediction = PredictionResult( + pdb_id="3F3E", + predicted_state="OF_open", + probabilities={"IF_open": 0.05, "OF_open": 0.80, "Occluded": 0.10, + "Intermediate": 0.05}, + features={ + "domain_TM1_TM7_distance": 22.5, + "rmsd_OF_open": 1.2, + "cavity_accessibility_out": 0.52, + }, +) + +result = explain(prediction) +print(result.text) +``` + +Plain-text output: + +``` +Structure 3F3E is predicted to be outward-facing open (80% confidence). + +Key structural evidence: +- The TM1–TM7 gate distance (22.50) supports an outward-facing open conformation. +- The RMSD to outward-open reference (3F3E) (1.200) supports an outward-facing open conformation. +... + +Importance method: heuristic. + +References: +- Singh et al., Science 2008 — DOI: 10.1126/science.1159299 — PubMed: 18403708 +``` + +Markdown output is available via `explain(prediction, output_format="markdown")`. + +## Importance methods + +The `explain()` function picks the best available importance source: + +| Priority | Method | When used | +|----------|--------|-----------| +| 1 | `provided` | Caller passes pre-computed importances (Person 3 pipeline) | +| 2 | `coefficient` | Linear-model coefficients are supplied | +| 3 | `permutation` | sklearn-compatible model + feature matrix | +| 4 | `heuristic` | Default stub: compares features to LeuT state profiles | + +### Heuristic mode (current default) + +While Person 3's models are in development, heuristic importance compares +each feature to literature-informed LeuT state profiles in +`confostate/explain/importance.py`. RMSD features matching the predicted +state receive higher scores; domain and cavity features are scored by +agreement with the profile. + +### Permutation importance + +When a trained model with `predict_proba` is available: + +```python +import numpy as np +from confostate.explain import explain + +result = explain( + prediction, + model=trained_model, + feature_matrix=X, # shape (n_samples, n_features) + feature_names=feature_cols, +) +``` + +### Provided importances + +Person 3 can pass SHAP or other importances directly: + +```python +from confostate.explain import FeatureImportance, explain + +importances = [ + FeatureImportance( + feature_name="domain_TM1_TM7_distance", + display_name="TM1–TM7 gate distance", + importance=0.85, + value=22.5, + direction="supports", + ), +] +result = explain(prediction, importances=importances) +``` + +## Citations + +`confostate/explain/citations.py` maps LeuT states and key structural +features to curated DOI and PubMed references. Citations are included +automatically in rendered output. + +Supported states: `IF_open`, `OF_open`, `Occluded`, `Intermediate`. + +## Integration with Person 5 (CLI) + +The planned `Classifier.classify()` API will call `explain()` internally. +Expected flow: + +``` +PDB → extract_features() → model.predict() → explain() → PredictionResult +``` + +## Testing + +```bash +pip install -e ".[dev]" +pytest tests/test_explain.py -v +``` + +## Future work + +- SHAP values for tree and neural models +- LLM polish pass (local Llama at ASU) +- Multi-family citation and profile tables +- HTML explanation renderer for CLI `--output-format html` diff --git a/docs/index.md b/docs/index.md index 5e74051..75d0bbc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,7 @@ scripts/download_structures scripts/extract_feature_vectors examples/LeuT_descriptors features/features.md +explainability api ``` diff --git a/explain/importance.py b/explain/importance.py deleted file mode 100644 index 6c21e81..0000000 --- a/explain/importance.py +++ /dev/null @@ -1,16 +0,0 @@ -# {'IF':0.8, -# 'OF': 0.05, -# 'OCC': 0.15} - -# family specific? - -def explain(ai_results): - print(f'your results are {ai_results}') - -def isOdd(num): - if num % 2 == 0: - return False - elif num % 2 != 0: - return True - else: - return None \ No newline at end of file diff --git a/explain/render.py b/explain/render.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_explain.py b/tests/test_explain.py new file mode 100644 index 0000000..c64abfc --- /dev/null +++ b/tests/test_explain.py @@ -0,0 +1,186 @@ +"""Tests for confostate.explain package.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from confostate.explain import ( + ExplanationResult, + FeatureImportance, + PredictionResult, + display_name, + explain, + state_label, +) +from confostate.explain.citations import format_citation, get_citations +from confostate.explain.importance import ( + extract_coefficient_importance, + extract_heuristic_importance, + extract_permutation_importance, + rank_importances, +) + + +@pytest.fixture +def sample_prediction() -> PredictionResult: + return PredictionResult( + pdb_id="3F3E", + predicted_state="OF_open", + probabilities={ + "IF_open": 0.05, + "OF_open": 0.80, + "Occluded": 0.10, + "Intermediate": 0.05, + }, + features={ + "domain_TM1_TM7_distance": 22.5, + "domain_gate_TM1_TM6_distance": 26.1, + "rmsd_OF_open": 1.2, + "rmsd_IF_open": 3.8, + "cavity_accessibility_out": 0.52, + "cavity_volume": 480.0, + }, + family="LeuT", + ) + + +def test_display_name(): + assert display_name("domain_TM1_TM7_distance") == "TM1–TM7 gate distance" + assert display_name("unknown_feature") == "unknown feature" + + +def test_state_label(): + assert state_label("OF_open") == "outward-facing open" + assert state_label("Custom_state") == "Custom state" + + +def test_heuristic_importance(sample_prediction): + importances = extract_heuristic_importance( + sample_prediction.features, + sample_prediction.predicted_state, + family="LeuT", + ) + assert len(importances) == len(sample_prediction.features) + names = {item.feature_name for item in importances} + assert "rmsd_OF_open" in names + top = rank_importances(importances, top_k=3) + assert len(top) == 3 + assert all(item.importance >= 0 for item in top) + + +def test_coefficient_importance(sample_prediction): + coefficients = { + "domain_TM1_TM7_distance": 0.5, + "rmsd_OF_open": -1.0, + "rmsd_IF_open": 0.3, + } + importances = extract_coefficient_importance( + coefficients, + sample_prediction.features, + sample_prediction.predicted_state, + ) + rmsd_of = next(i for i in importances if i.feature_name == "rmsd_OF_open") + assert rmsd_of.direction == "opposes" + assert rmsd_of.importance < 0 + + +class DummyModel: + """Minimal sklearn-like model for permutation-importance tests.""" + + def predict_proba(self, X: np.ndarray) -> np.ndarray: + # Higher domain_TM1_TM7_distance -> more OF_open probability. + scores = X[:, 0] if X.shape[1] >= 1 else np.zeros(len(X)) + of_prob = np.clip(scores / 30.0, 0.0, 1.0) + if_prob = 1.0 - of_prob + return np.column_stack([if_prob, of_prob]) + + +def test_permutation_importance(): + feature_names = ["domain_TM1_TM7_distance", "rmsd_OF_open"] + X = np.array( + [ + [22.0, 1.2], + [18.0, 2.5], + [20.0, 1.8], + ] + ) + model = DummyModel() + baseline_proba = model.predict_proba(X)[0] + importances = extract_permutation_importance( + model, + X, + feature_names, + baseline_proba, + random_state=42, + ) + assert len(importances) == 2 + assert importances[0].feature_name in feature_names + + +def test_get_citations(sample_prediction): + citations = get_citations( + predicted_state="OF_open", + feature_names=["domain_TM1_TM7_distance", "rmsd_OF_open"], + family="LeuT", + ) + # State and features share Singh et al. 2008 — deduplicated. + assert len(citations) == 1 + assert citations[0].key == "OF_open" + formatted = format_citation(citations[0]) + assert "DOI" in formatted + assert "PubMed" in formatted + + +def test_get_citations_deduplicates_shared_doi(): + citations = get_citations( + predicted_state="Occluded", + feature_names=["rmsd_Occluded", "cavity_volume"], + family="LeuT", + ) + dois = [c.doi for c in citations if c.doi] + assert len(dois) == len(set(dois)) + assert len(citations) == 2 + + +def test_explain_returns_result(sample_prediction): + result = explain(sample_prediction) + assert isinstance(result, ExplanationResult) + assert result.pdb_id == "3F3E" + assert result.predicted_state == "OF_open" + assert result.confidence == pytest.approx(0.80) + assert result.method == "heuristic" + assert len(result.top_features) <= 5 + assert "3F3E" in result.text + assert "outward-facing open" in result.text + assert "## Prediction" in result.markdown + + +def test_explain_text_and_markdown_formats(sample_prediction): + text = explain(sample_prediction, output_format="text") + markdown = explain(sample_prediction, output_format="markdown") + assert isinstance(text, str) + assert isinstance(markdown, str) + assert "References:" in text + assert "### References" in markdown + + +def test_explain_from_dict(sample_prediction): + result = explain(sample_prediction.__dict__) + assert result.pdb_id == "3F3E" + + +def test_explain_with_provided_importances(sample_prediction): + provided = [ + FeatureImportance( + feature_name="domain_TM1_TM7_distance", + display_name="TM1–TM7 gate distance", + importance=0.9, + value=22.5, + direction="supports", + ) + ] + result = explain(sample_prediction, importances=provided, top_k=1) + assert result.method == "provided" + assert len(result.top_features) == 1 + assert result.top_features[0].feature_name == "domain_TM1_TM7_distance" From d7d2436b948a4d7cfd4542a9e0d18230f783e335 Mon Sep 17 00:00:00 2001 From: lrepa Date: Mon, 10 Aug 2026 15:47:36 -0700 Subject: [PATCH 18/18] ruffed it --- confostate/explain/__init__.py | 8 ++------ confostate/explain/importance.py | 4 +--- confostate/explain/render.py | 8 ++------ 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/confostate/explain/__init__.py b/confostate/explain/__init__.py index 4e70b87..ebc335d 100644 --- a/confostate/explain/__init__.py +++ b/confostate/explain/__init__.py @@ -119,13 +119,9 @@ def explain( family=prediction.family, ) - confidence = prediction.probabilities.get( - prediction.predicted_state, 0.0 - ) + confidence = prediction.probabilities.get(prediction.predicted_state, 0.0) text = render_text(prediction, top_features, citations, method) - markdown = render_markdown( - prediction, top_features, citations, method - ) + markdown = render_markdown(prediction, top_features, citations, method) result = ExplanationResult( pdb_id=prediction.pdb_id, diff --git a/confostate/explain/importance.py b/confostate/explain/importance.py index e706879..f589a02 100644 --- a/confostate/explain/importance.py +++ b/confostate/explain/importance.py @@ -142,9 +142,7 @@ def extract_permutation_importance( for _ in range(n_repeats): permuted = feature_matrix.copy() if n_samples > 1: - other_values = np.delete( - feature_matrix[:, col_idx], row_idx - ) + other_values = np.delete(feature_matrix[:, col_idx], row_idx) permuted[row_idx, col_idx] = rng.choice(other_values) else: scale = max(abs(original_value), 1.0) diff --git a/confostate/explain/render.py b/confostate/explain/render.py index 2511135..035f7b7 100644 --- a/confostate/explain/render.py +++ b/confostate/explain/render.py @@ -37,9 +37,7 @@ def render_text( method: str, ) -> str: """Render a plain-text explanation.""" - confidence = prediction.probabilities.get( - prediction.predicted_state, 0.0 - ) + confidence = prediction.probabilities.get(prediction.predicted_state, 0.0) state_name = state_label(prediction.predicted_state) lines = [ ( @@ -74,9 +72,7 @@ def render_markdown( method: str, ) -> str: """Render a Markdown explanation.""" - confidence = prediction.probabilities.get( - prediction.predicted_state, 0.0 - ) + confidence = prediction.probabilities.get(prediction.predicted_state, 0.0) state_name = state_label(prediction.predicted_state) lines = [ f"## Prediction: {prediction.pdb_id}",