Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 41 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,16 @@ WaterFlow processes structure files through several stages to create training-re
- For atoms with alternate conformations, the highest-occupancy conformer is selected

**Crystal Contact Detection**
- Uses PyMOL's `symexp` to generate symmetry mates within 5.0Å cutoff
- Symmetry mate atoms are included as additional protein context when `include_mates=True`
- Mate atoms are stored separately for proper handling during training
- Uses PyMOL's `symexp` to generate symmetry mates, keeping whole residues and whole ligand entities with any atom within the cutoff of the ASU. Runs only when `include_mates=True`; a no-mates cache never invokes PyMOL
- Protein mates and ligand mates are selected separately by PyMOL's own classifiers, so `is_ligand` stays exact for mate nodes too
- **Mate waters are never selected.** A mate water is a symmetry image of an ASU water, which is what the model predicts, so keeping it as context leaks the label
- Symmetry also maps atoms onto themselves (special positions) and reaches one residue through two operators. Mate atoms within 0.3Å of an ASU atom, a target water, or an already-kept mate atom are dropped (`dedup_mate_atoms`); mate ligands are judged whole, so a ligand is never fragmented (`dedup_mate_ligands_by_residue`)
- A mate keeps its source residue's `(chain, res_id, ins_code)`, so it inherits that residue's ESM row through `emb_res_idx` instead of a zero vector, and it joins the distance-filter reference so a water in a crystal contact — near a neighbour surface but far from the ASU — is not dropped as solvent-far

**Graph Representation**
- Node types: `protein` (ASU + symmetry mates + ligands), `water` (ground truth)
- ASU ligand atoms are appended after ASU and mate atoms and carry the boolean `is_ligand` mask plus `residue_index = -1` (they have no residue embedding, so residue pooling masks them out)
- `is_ligand` marks **ASU ligands only**. Symmetry-mate generation is currently unfiltered, so mate nodes can include HETATM and water atoms that `is_ligand` does not mark — see `TODO(mates)` in `ProteinWaterDataset._preprocess_one`. Don't treat `is_ligand` as an exhaustive ligand selector
- Ligand atoms are appended after ASU and mate atoms and carry the boolean `is_ligand` mask plus `residue_index = -1` (they have no residue embedding, so residue pooling masks them out)
- `is_mate` marks every non-ASU node, protein or ligand. The flow prior anchors on `~is_mate` so sampled waters start where the targets live
- Edge types (defined in `src/constants.py`):
- `('protein', 'pp', 'protein')`: protein-protein edges
- `('protein', 'pw', 'water')`: protein to water
Expand Down Expand Up @@ -114,20 +116,22 @@ Preprocessed data is cached under `--processed_dir` in a three-layer architectur
│ - protein_pos: centered protein coordinates (N, 3)
│ - protein_x: element one-hot encoding (N, 16)
│ - protein_res_idx: residue indices for grouping
│ - is_ligand: bool mask marking the appended ASU ligand atoms (N,)
│ - is_ligand: bool mask marking the ligand atoms (N,)
│ - is_mate: bool mask marking the symmetry-mate atoms (N,)
│ - emb_res_idx: embedding row per atom; -1 means no row (N,)
Comment on lines 116 to +121

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you might want to rename protein_pos/x/res_idx to be more generic, maybe like node_pos/x/res_idx (referencing your line 124: Node order is [ASU protein | mate protein | ASU ligand | mate ligand]), since at this point they are not just protein atoms any more, and the array shape annotation N would get confusing.

│ - water_pos, water_x: water coordinates and features
│ - num_asu_protein: ASU atom count (mate boundary metadata)
│ # Note: When include_mates=True, mate atoms are concatenated into
│ # protein_pos/protein_x, and ASU ligand atoms are appended after those.
│ # Node order is [ASU protein | mates | ASU ligands]. Recover blocks via:
│ # ASU protein atoms = protein_pos[:num_asu_protein]
│ # ASU ligand atoms = protein_pos[is_ligand] # always last
│ # Mate atoms = protein_pos[num_asu_protein:][~is_ligand[num_asu_protein:]]
│ # Node order is [ASU protein | mate protein | ASU ligand | mate ligand],
│ # so the two masks recover every block:
│ # ASU protein = ~is_mate & ~is_ligand (== the first num_asu_protein)
│ # mate protein = is_mate & ~is_ligand
│ # ASU ligand = ~is_mate & is_ligand
│ # mate ligand = is_mate & is_ligand
│ #
│ # is_ligand marks ASU ligands ONLY -- it is not an exhaustive ligand
│ # selector. The mate block is unfiltered (see TODO(mates) in
│ # _preprocess_one), so mate atoms may include HETATM/ligand/water atoms
# that are NOT marked by is_ligand.
│ # emb_res_idx indexes the ESM table: mate atoms carry the row of the ASU
│ # residue they are a symmetry image of, and every ligand carries -1,
│ # which reads as a zero row.
├── <geometry_dir>/_filter_meta.json # settings this directory was built with
├── esm/ # ESM embeddings (per-residue)
│ └── <pdb_id>_final.pt
│ - residue_embeddings: ESM3 embeddings (N_res, embed_dim)
Expand All @@ -153,13 +157,29 @@ configs that produce different graphs never share a directory:

The base name comes from `--geometry_cache_name` (default `geometry`).

**Filter Provenance:**

Filtering happens *before* the cache is written, so the thresholds are a property of the
directory, not of the run reading it — and the `.pt` files record none of them. Each geometry
directory therefore carries a `_filter_meta.json` sidecar holding the per-water filters and
their toggles, the structure-level checks that decide which entries exist at all
(`min_water_residue_ratio`, `max_com_dist`, `max_clash_fraction`, `clash_dist`,
`interface_dist_threshold`), and the graph parameters behind the cached PP edges (`cutoff`,
`max_neighbors`).

The first run with `preprocess=True` writes it; every later run compares against it and
**refuses to start** on a mismatch rather than appending differently filtered entries to the
same directory. A disabled filter records `null` for its threshold, which cannot have changed
the cached waters. Directories built before this existed have no sidecar: they load, and warn
that their provenance is unverifiable, until a preprocessing run stamps them — so check your
thresholds match the cache before that first run.

**Cache Generation Notes:**
- Geometry cache is generated automatically when `preprocess=True` (default)
- ESM/SLAE caches require running the respective `generate_*_embeddings.py` scripts first
- Preprocessing failures are logged to `<geometry_dir>/preprocessing_failures.log`
- Geometry caches built before ligand support lack the `is_ligand` field and will fail to
load with a `KeyError`. Delete the geometry cache directory and let it regenerate — the
cached graphs are stale, not merely missing a field
- A cache file missing any field the loader reads (`is_ligand`, `is_mate`, `emb_res_idx`, …)
raises `KeyError`. Delete the geometry cache directory and let it regenerate

## Environment Setup

Expand Down Expand Up @@ -303,7 +323,7 @@ These checks determine whether a structure is included in training:
| `--max_com_dist` | `25.0` | Max protein-water center-of-mass distance (A) |
| `--max_clash_fraction` | `0.05` | Max fraction of waters clashing with protein |
| `--clash_dist` | `2.0` | Distance threshold for clash detection (A) |
| `--min_water_residue_ratio` | `0.6` | Minimum waters per residue ratio |
| `--min_water_residue_ratio` | `0.1` | Minimum waters per residue ratio |

### Per-Water Quality Filters

Expand All @@ -313,7 +333,7 @@ These filters remove individual low-quality waters (can be toggled):
|-----------|---------|-------------|-------------|
| `--max_protein_dist` | `5.0` | `--no_filter_by_distance` | Remove waters far from protein |
| `--min_edia` | `0.4` | `--no_filter_by_edia` | Remove waters with low EDIA scores |
| `--max_bfactor_zscore` | `1.5` | `--no_filter_by_bfactor` | Remove waters with high B-factor |
| `--max_bfactor_zscore` | `2.0` | `--no_filter_by_bfactor` | Remove waters with high B-factor |

<details>
<summary><strong>About EDIA Scores</strong></summary>
Expand Down
5 changes: 2 additions & 3 deletions scripts/generate_slae_embeddings.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"""
Precompute SLAE embeddings for protein structures and save to separate cache files.

NOTE: This SLAE encoder is legacy and is NOT currently used. We primarily use the
ESM encoder (see scripts/generate_esm_embeddings.py). This script is retained for
reference/reproducibility only.
NOTE: The SLAE encoder is NOT currently used. We primarily use the ESM encoder
(see scripts/generate_esm_embeddings.py); this script is kept for reproducibility.

This script:
1. Reads a split file containing PDB entries
Expand Down
4 changes: 2 additions & 2 deletions scripts/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,10 @@ def _extract_dataset_filter_config(config: dict) -> dict:
"max_clash_fraction": config.get("max_clash_fraction", 0.05),
"clash_dist": config.get("clash_dist", 2.0),
"interface_dist_threshold": config.get("interface_dist_threshold", 4.0),
"min_water_residue_ratio": config.get("min_water_residue_ratio", 0.6),
"min_water_residue_ratio": config.get("min_water_residue_ratio", 0.1),
"max_protein_dist": config.get("max_protein_dist", 5.0),
"min_edia": config.get("min_edia", 0.4),
"max_bfactor_zscore": config.get("max_bfactor_zscore", 1.5),
"max_bfactor_zscore": config.get("max_bfactor_zscore", 2.0),
"filter_by_distance": config.get("filter_by_distance", True),
"filter_by_edia": config.get("filter_by_edia", True),
"filter_by_bfactor": config.get("filter_by_bfactor", True),
Expand Down
15 changes: 11 additions & 4 deletions scripts/train.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,11 @@ def parse_args():
p.add_argument(
"--min_water_residue_ratio",
type=float,
default=0.6,
help="Quality: minimum waters/residue ratio required per structure.",
default=0.1,
help=(
"Quality: minimum waters/residue ratio required per structure. Applied "
"at cache-write time, so it decides which structures the cache holds."
),
)

# per-water filtering (toggleable)
Expand All @@ -169,8 +172,12 @@ def parse_args():
p.add_argument(
"--max_bfactor_zscore",
type=float,
default=1.5,
help="Water filter: remove waters with normalized B-factor above this threshold.",
default=2.0,
help=(
"Water filter: remove waters with normalized B-factor above this "
"threshold. Baked in at cache-write time, so a warm cache built at a "
"different value is refused rather than extended."
),
)
p.add_argument(
"--no_filter_by_distance",
Expand Down
3 changes: 0 additions & 3 deletions src/constants.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why removing the comments?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it was over-explanatory in this case, since we do mention this later in the code.

Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@
NODE_FEATURE_DIM = 16 # Default node scalar feature dimension

# Native widths of the cached embeddings produced by scripts/generate_*_embeddings.py.
# These are fixed by the upstream models, not tunable: ESM3-open emits 1536-wide
# per-residue vectors, SLAE emits 128-wide per-atom vectors. Cached encoders take the
# width as a required config key (embedding_dim); these are the values to pass.
ESM_EMBEDDING_DIM = 1536
SLAE_EMBEDDING_DIM = 128

Expand Down
Loading
Loading