Skip to content

Commit 6519456

Browse files
authored
Merge pull request #6 from Becksteinlab:person2-feature-engg
Feature Engineering second take with new addtions
2 parents 7928b2a + a5b54bf commit 6519456

5 files changed

Lines changed: 160 additions & 21 deletions

File tree

confostate/features/_structure.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,6 @@ def load_structure(
9090
return structure
9191

9292

93-
def sort_by_resid(atomgroup: AtomGroup) -> AtomGroup:
94-
"""Return atom group sorted by residue number."""
95-
return atomgroup[np.argsort(atomgroup.resids)]
96-
97-
9893
def center_of_mass(atomgroup: AtomGroup) -> np.ndarray:
9994
if len(atomgroup) == 0:
10095
raise ValueError("Cannot compute center of mass for empty atom group")

confostate/features/domains.py

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@
1111
angle_between_vectors,
1212
center_of_mass,
1313
helix_axis,
14+
load_structure,
1415
pairwise_distance,
1516
)
17+
from confostate.features.rmsd import (
18+
LEUT_REFERENCE_STRUCTURES,
19+
_resolve_reference_path,
20+
)
1621

1722
LEUT_TM_HELICES: dict[str, tuple[int, int]] = {
1823
"TM1": (22, 52),
@@ -36,29 +41,35 @@
3641
("TM3", "TM10"),
3742
)
3843

44+
_DOMAIN_METRIC_KEYS = (
45+
"domain_TM1_TM7_distance",
46+
"domain_TM1_TM7_angle",
47+
"domain_TM1_TM6_distance",
48+
"domain_TM1_TM6_angle",
49+
"domain_TM5_TM7_distance",
50+
"domain_TM5_TM7_angle",
51+
"domain_TM3_TM10_distance",
52+
"domain_TM3_TM10_angle",
53+
"domain_gate_TM1_TM6_distance",
54+
)
3955

40-
def extract_domain_features(
56+
57+
def _domain_geometry(
4158
structure: StructureData,
42-
tm_helices: Optional[dict[str, tuple[int, int]]] = None,
43-
domain_pairs: tuple[tuple[str, str], ...] = LEUT_DOMAIN_PAIRS,
59+
tm_helices: dict[str, tuple[int, int]],
60+
domain_pairs: tuple[tuple[str, str], ...],
4461
) -> dict[str, float]:
45-
"""
46-
Compute pairwise helix COM distances and inter-helix angles.
47-
48-
Uses MDAnalysis selections and ``AtomGroup.center_of_mass()``.
49-
"""
50-
helices = tm_helices or LEUT_TM_HELICES
51-
features: dict[str, float] = {}
52-
62+
"""Compute absolute inter-helix distances and angles."""
5363
coms: dict[str, np.ndarray] = {}
5464
axes: dict[str, np.ndarray] = {}
55-
for name, (start, end) in helices.items():
65+
for name, (start, end) in tm_helices.items():
5666
ag = structure.select_ca_range(start, end)
5767
if len(ag) == 0:
5868
continue
5969
coms[name] = center_of_mass(ag)
6070
axes[name] = helix_axis(ag)
6171

72+
features: dict[str, float] = {}
6273
for helix_a, helix_b in domain_pairs:
6374
key_base = f"domain_{helix_a}_{helix_b}"
6475
if helix_a not in coms or helix_b not in coms:
@@ -80,3 +91,69 @@ def extract_domain_features(
8091
features["domain_gate_TM1_TM6_distance"] = float("nan")
8192

8293
return features
94+
95+
96+
def _domain_deltas(
97+
base_features: dict[str, float],
98+
reference_dir: str,
99+
reference_structures: dict[str, str],
100+
tm_helices: dict[str, tuple[int, int]],
101+
domain_pairs: tuple[tuple[str, str], ...],
102+
) -> dict[str, float]:
103+
"""Compute domain metric deltas vs each unique reference PDB."""
104+
deltas: dict[str, float] = {}
105+
unique_refs = sorted(set(reference_structures.values()))
106+
107+
for ref_pdb_id in unique_refs:
108+
try:
109+
ref_path = _resolve_reference_path(ref_pdb_id, reference_dir)
110+
except FileNotFoundError:
111+
continue
112+
113+
ref_structure = load_structure(str(ref_path), pdb_id=ref_pdb_id)
114+
ref_features = _domain_geometry(
115+
ref_structure, tm_helices, domain_pairs
116+
)
117+
118+
for key in _DOMAIN_METRIC_KEYS:
119+
if key not in base_features or key not in ref_features:
120+
continue
121+
base_val = base_features[key]
122+
ref_val = ref_features[key]
123+
if np.isnan(base_val) or np.isnan(ref_val):
124+
deltas[f"{key}_delta_vs_{ref_pdb_id}"] = float("nan")
125+
else:
126+
deltas[f"{key}_delta_vs_{ref_pdb_id}"] = float(
127+
base_val - ref_val
128+
)
129+
130+
return deltas
131+
132+
133+
def extract_domain_features(
134+
structure: StructureData,
135+
tm_helices: Optional[dict[str, tuple[int, int]]] = None,
136+
domain_pairs: tuple[tuple[str, str], ...] = LEUT_DOMAIN_PAIRS,
137+
reference_dir: Optional[str] = None,
138+
reference_structures: Optional[dict[str, str]] = None,
139+
include_deltas: bool = True,
140+
) -> dict[str, float]:
141+
"""
142+
Compute pairwise helix COM distances, angles, and optional deltas.
143+
144+
When ``reference_dir`` is set, also returns deltas vs each curated
145+
reference PDB (same references as ``rmsd.py``), e.g.
146+
``domain_TM1_TM7_distance_delta_vs_3F3E``.
147+
"""
148+
helices = tm_helices or LEUT_TM_HELICES
149+
features = _domain_geometry(structure, helices, domain_pairs)
150+
151+
if include_deltas and reference_dir:
152+
refs = reference_structures or LEUT_REFERENCE_STRUCTURES
153+
features.update(
154+
_domain_deltas(
155+
features, reference_dir, refs, helices, domain_pairs
156+
)
157+
)
158+
159+
return features

confostate/features/pipeline.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ def extract_features(
4040
features.update(
4141
extract_cavity_features(structure, membrane_normal=membrane_normal)
4242
)
43-
features.update(extract_domain_features(structure))
43+
features.update(
44+
extract_domain_features(
45+
structure,
46+
reference_dir=reference_dir or str(Path(pdb_path).parent),
47+
)
48+
)
4449
features.update(
4550
extract_orientation_features(
4651
structure, annotations_row=annotations_row

docs/features/features.md

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ Each row = one PDB structure. Columns fall into three categories:
5656

5757
| Category | Columns | Status | Source |
5858
|----------|---------|--------|--------|
59-
| **Computed features (X)** | `cavity_*`, `domain_*`, `rmsd_*`, `opm_*`, `orientation_principal_axis_*` | **Live computation** | MDAnalysis + SciPy on PDB coordinates from RCSB |
59+
| **Computed features (X)** | `cavity_*`, `domain_*`, `domain_*_delta_vs_*`, `rmsd_*`, `opm_*`, `orientation_principal_axis_*` | **Live computation** | MDAnalysis + SciPy on PDB coordinates from RCSB |
6060
| **Label (y)** | `conformation` | **Unverified stub** | Copied from annotations CSV; `conformation_status = literature_estimate` |
6161
| **Metadata** | `pdb_id`, `file_path` | **Real** | PDB ID list + local file path |
6262

@@ -108,6 +108,7 @@ Person 2 → features (X) extract_features() / feature vectors CS
108108
Person 3 → join X + y, train model confostate/data/datasets.py, models/
109109
```
110110

111+
## API
111112

112113
### `extract_features(pdb_path, ...)`
113114

@@ -152,7 +153,16 @@ Inter-helix distances and angles for LeuT TM helices.
152153
| `domain_TM1_TM6_distance` | COM distance between TM1 and TM6 |
153154
| `domain_TM5_TM7_distance` | COM distance between TM5 and TM7 |
154155
| `domain_TM3_TM10_distance` | COM distance between TM3 and TM10 |
155-
| `domain_gate_TM1_TM6_distance` | Gate-opening distance (TM1–TM6) |
156+
| `domain_gate_TM1_TM6_distance` | Gate-opening distance (TM1–TM6) | Computed |
157+
158+
For each reference PDB (`3F3A`, `3F3E`, `3F4J`, `3USI`), delta features are also
159+
emitted when reference files are available in `reference_dir`:
160+
161+
| Feature pattern | Description | Source |
162+
|---|---|---|
163+
| `domain_*_distance_delta_vs_<PDB>` | Distance change vs reference structure | Computed |
164+
| `domain_*_angle_delta_vs_<PDB>` | Angle change vs reference structure | Computed |
165+
| `domain_gate_TM1_TM6_distance_delta_vs_<PDB>` | Gate distance change vs reference | Computed |
156166

157167
Helix boundaries are in `LEUT_TM_HELICES`.
158168

@@ -185,6 +195,35 @@ available; otherwise estimates tilt/rotation from the structure principal axis.
185195
| `opm_depth` | Centroid depth relative to membrane plane (Å) |
186196
| `orientation_principal_axis_x/y/z` | Unit vector of first principal component |
187197

198+
## Feature column manifest
199+
200+
Complete list of columns in `extract_features()` output and
201+
`leu_t_feature_vectors.csv`:
202+
203+
| Column | Source | Notes |
204+
|--------|--------|-------|
205+
| `cavity_volume` | Computed (PDB) | Convex hull of binding-pocket atoms |
206+
| `cavity_accessibility_in` | Computed (PDB) | Inward membrane-side exposure proxy |
207+
| `cavity_accessibility_out` | Computed (PDB) | Outward membrane-side exposure proxy |
208+
| `domain_*_distance` | Computed (PDB) | Absolute TM helix COM distances |
209+
| `domain_*_angle` | Computed (PDB) | Absolute TM helix axis angles |
210+
| `domain_gate_TM1_TM6_distance` | Computed (PDB) | Gate helix distance |
211+
| `domain_*_delta_vs_<PDB>` | Computed (PDB) | Change vs reference structure |
212+
| `opm_tilt_angle` | Computed (PDB) or annotations | OPM CSV used when not `N/A` |
213+
| `opm_rotation_angle` | Computed (PDB) or annotations | OPM CSV used when not `N/A` |
214+
| `opm_depth` | Computed (PDB) or annotations | OPM CSV used when not `N/A` |
215+
| `opm_tm_count` | Annotations only | When provided by Person 1 |
216+
| `orientation_principal_axis_x/y/z` | Computed (PDB) | Principal axis components |
217+
| `rmsd_OF_open` | Computed (PDB) | RMSD vs 3F3E |
218+
| `rmsd_IF_open` | Computed (PDB) | RMSD vs 3F3A |
219+
| `rmsd_Occluded` | Computed (PDB) | RMSD vs 3F4J |
220+
| `rmsd_Intermediate` | Computed (PDB) | RMSD vs 3USI |
221+
| `rmsd_min` | Computed (PDB) | Minimum RMSD across references |
222+
| `rmsd_best_state_index` | Computed (PDB) | Index of closest reference state |
223+
| `pdb_id` | Metadata | From filename (batch export only) |
224+
| `file_path` | Metadata | Local PDB path (batch export only) |
225+
| `conformation` | Annotations CSV | **Provisional label** — see provenance section |
226+
188227
## Dependencies on Person 1 (data)
189228

190229
Person 1 will deliver a verified `data/annotations/leu_t_transporters.csv`.

tests/test_features.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,34 @@ def test_cavity_features(structure):
4343

4444

4545
def test_domain_features(structure):
46-
features = extract_domain_features(structure)
46+
features = extract_domain_features(structure, reference_dir=str(INPUT_DIR))
4747
assert "domain_TM1_TM7_distance" in features
4848
assert features["domain_TM1_TM7_distance"] > 0
4949
assert 0 <= features["domain_TM1_TM7_angle"] <= 180
5050

5151

52+
def test_domain_delta_features_self(structure):
53+
"""Delta vs same structure (3F3E) should be approximately zero."""
54+
if not (INPUT_DIR / "3F3E.pdb").exists():
55+
pytest.skip("Reference PDB 3F3E not downloaded")
56+
features = extract_domain_features(structure, reference_dir=str(INPUT_DIR))
57+
key = "domain_TM1_TM7_distance_delta_vs_3F3E"
58+
assert key in features
59+
assert features[key] == pytest.approx(0.0, abs=0.01)
60+
61+
62+
def test_domain_delta_features_different_conformation():
63+
"""IF-open 3F3A should have non-zero delta vs OF-open reference 3F3E."""
64+
path = INPUT_DIR / "3F3A.pdb"
65+
if not path.exists() or not (INPUT_DIR / "3F3E.pdb").exists():
66+
pytest.skip("Reference PDBs not downloaded")
67+
structure = load_structure(str(path), pdb_id="3F3A")
68+
features = extract_domain_features(structure, reference_dir=str(INPUT_DIR))
69+
key = "domain_TM1_TM7_distance_delta_vs_3F3E"
70+
assert key in features
71+
assert abs(features[key]) > 0.01
72+
73+
5274
def test_orientation_features(structure):
5375
features = extract_orientation_features(structure)
5476
assert "opm_tilt_angle" in features
@@ -74,6 +96,7 @@ def test_extract_features(sample_pdb):
7496
)
7597
assert "cavity_volume" in features
7698
assert "domain_TM1_TM7_distance" in features
99+
assert "domain_TM1_TM7_distance_delta_vs_3F3E" in features
77100
assert "opm_tilt_angle" in features
78101
# OPM columns are N/A in CSV, so tilt must be computed from coordinates.
79102
assert features["opm_tilt_angle"] > 0

0 commit comments

Comments
 (0)