-
Notifications
You must be signed in to change notification settings - Fork 16
Add functions to calculate cycle closure #107
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 48 commits
2276ea7
32338ff
ec1d008
e48668a
5edf55e
2c68605
31477ce
6b2dfd0
0b8329f
675184a
bbbca3f
7f1d209
ddafe13
054b372
ec405d8
ccd83ee
6bbca0a
b24329e
e8099c7
378ed29
f69667c
790ce3a
1813795
5c538e2
7a990c6
5c70d2d
2362af4
fc26bbf
6739d54
cc96b78
7e9f804
2d126b7
030ac30
7c73c45
67d7c42
594a498
d839374
5666bb5
2318569
3669f55
0d8dbb1
082cd6c
a42ca92
dade9fd
3673bca
c1ff1e5
a76a12e
fe608b0
706637b
f4c6990
4c8448f
1838ac4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
|
|
||
| import copy | ||
| import itertools | ||
| import math | ||
| import pathlib | ||
| import warnings | ||
| from dataclasses import asdict | ||
|
|
@@ -946,3 +947,155 @@ def draw_graph( | |
| else: | ||
| fig.savefig(filename, bbox_inches="tight", dpi=300) | ||
| plt.close(fig) | ||
|
|
||
| def get_cycle_closure_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame: | ||
| """ | ||
| Calculate cycle closure errors for all cycles in the network. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| max_cycle_length : int, default 5 | ||
| Only consider cycles up to this length. Default 5. | ||
|
|
||
| Returns | ||
| ------- | ||
| The pandas DataFrame will have the following columns: | ||
| - source | ||
| - cycle | ||
| - cc (kcal/mol) | ||
| - cc_per_edge (kcal/mol) | ||
| - cc_unc_normalized | ||
|
hannahbaumann marked this conversation as resolved.
|
||
| Sorted by source and cycle closure error descending. | ||
|
|
||
| Notes | ||
| ----- | ||
| Three cycle closure metrics are calculated: | ||
|
|
||
| - ``cc (kcal/mol)``: the raw absolute sum of DDGs around the cycle. Units: kcal/mol. | ||
|
|
||
| - ``cc_per_edge (kcal/mol)``: the cycle closure divided by the square root of the cycle | ||
| length, to allow comparison across different cycle lengths; | ||
| see Baumann et al. (DOI 10.1021/acs.jctc.3c00282). Units: kcal/mol. | ||
|
|
||
| - ``cc_unc_normalized``: the cycle closure error divided by its propagated uncertainty, | ||
| calculated as ``abs(sum_ddgs) / sqrt(sum_var)``. | ||
|
hannahbaumann marked this conversation as resolved.
hannahbaumann marked this conversation as resolved.
|
||
| """ | ||
| df = self.get_relative_dataframe() | ||
| comp_df = df[df["computational"]] | ||
|
|
||
| rows = [] | ||
| for source, source_df in comp_df.groupby("source"): | ||
| edge_ddg = {(row["labelA"], row["labelB"]): row["DDG (kcal/mol)"] for _, row in source_df.iterrows()} | ||
| edge_uncertainty = { | ||
| (row["labelA"], row["labelB"]): row["uncertainty (kcal/mol)"] for _, row in source_df.iterrows() | ||
| } | ||
|
|
||
| network = nx.DiGraph() | ||
| for a, b in edge_ddg: | ||
| network.add_edge(a, b) | ||
|
|
||
| cycles = [c for c in nx.simple_cycles(network.to_undirected()) if len(c) <= max_cycle_length] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've been staring at this for a while and it's not immediately clear to me if calling Would this be intended behaviour? If not, should
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't this show up in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It looks like
But I could also switch to
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thinking about this more, maybe G = nx.Graph()
nx.add_cycle(G, [0, 1, 2, 3])
nx.add_cycle(G, [0, 3, 4, 5])
list(nx.simple_cycles(G))
> [[0, 1, 2, 3], [0, 1, 2, 3, 4, 5], [0, 3, 4, 5]]Would we want the values on the smallest possible cycles of the graph? The super cycles formed by combinations of cycles don't tell us anything new do they?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think my thought process from back when I was doing that as a grad student was that if we included more cycles (so also the super cycles) the risk of "missing" bad edges would be lower where by missing I mean that some cycles can give a low cycle closure by chance (bad edges but cancelation of errors). And then I included the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay that makes sense and seems to agree with the observations in https://pubs.acs.org/doi/10.1021/acs.jcim.5c00554 where closing the basis cycles does not mean the larger cycles will close, lets go with simple_cycles and come back to this if we find any issues!
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be good to record this somewhere in an issue - otherwise we might never come back to it / remember why we made this decision.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably should document this cycle choosing behaviour too somewhere user facing (it'd be ok as a separate issue / PR).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Opened an issue here: #218
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wait do we want this to be
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As discussed offline, the directed graph would not catch something like this as a cycle: A->B; C->B; C->A.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Spoke on slack about this, this does not pick up cycles in the directed case for cycles like
hannahbaumann marked this conversation as resolved.
|
||
|
|
||
| for cycle in cycles: | ||
| sum_ddgs = 0.0 | ||
| sum_var = 0.0 | ||
| for i, lig in enumerate(cycle): | ||
| lig_a = lig | ||
| lig_b = cycle[i + 1] if i < len(cycle) - 1 else cycle[0] | ||
|
|
||
| # depending on the direction the edge was calculated, | ||
| # the sign of the DDG has to change | ||
| if (lig_a, lig_b) in edge_ddg: | ||
| sum_ddgs += edge_ddg[(lig_a, lig_b)] | ||
| sum_var += edge_uncertainty[(lig_a, lig_b)] ** 2 | ||
| elif (lig_b, lig_a) in edge_ddg: | ||
| sum_ddgs -= edge_ddg[(lig_b, lig_a)] | ||
| sum_var += edge_uncertainty[(lig_b, lig_a)] ** 2 | ||
| else: | ||
| # Edge missing from network; skip this cycle | ||
| break | ||
|
|
||
| else: | ||
| cc = abs(sum_ddgs) | ||
| # Normalize by sqrt(cycle length) to allow comparison across | ||
| # different cycle lengths | ||
| cc_per_edge = cc / math.sqrt(len(cycle)) | ||
| cc_z_score = cc / math.sqrt(sum_var) if sum_var > 0 else np.nan | ||
| rows.append( | ||
| { | ||
| "source": source, | ||
| "cycle": tuple(cycle), | ||
| "cc (kcal/mol)": cc, | ||
| "cc_per_edge (kcal/mol)": cc_per_edge, | ||
| "cc_unc_normalized": cc_z_score, | ||
| } | ||
| ) | ||
|
|
||
| return ( | ||
| pd.DataFrame( | ||
| rows, | ||
| columns=["source", "cycle", "cc (kcal/mol)", "cc_per_edge (kcal/mol)", "cc_unc_normalized"], | ||
| ) | ||
| .sort_values(["source", "cc (kcal/mol)"], ascending=[True, False]) | ||
| .reset_index(drop=True) | ||
| ) | ||
|
|
||
| def get_cycle_closure_edge_statistics_dataframe(self, max_cycle_length: int = 5) -> pd.DataFrame: | ||
| """ | ||
| For each simulated edge, report how many cycles it appears in and | ||
| the mean and max cycle closure error of those cycles per source. | ||
|
|
||
| The cycle closure values are based on ``cc_per_edge (kcal/mol)``, | ||
| defined as the absolute cycle closure divided by the square root of the cycle length. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| max_cycle_length : int, default 5 | ||
| Only consider cycles up to this length. Defaults to 5. | ||
|
|
||
| Returns | ||
| ------- | ||
| The pandas DataFrame will have the following columns: | ||
| - source | ||
| - ligandA | ||
| - ligandB | ||
| - n_cycles | ||
| - mean_cc_per_edge (kcal/mol) | ||
| - max_cc_per_edge (kcal/mol) | ||
|
|
||
| Sorted by source and mean cycle closure error descending. | ||
| """ | ||
| from collections import defaultdict | ||
|
|
||
| cc_df = self.get_cycle_closure_dataframe(max_cycle_length=max_cycle_length) | ||
|
|
||
| rows = [] | ||
| for source, source_cc_df in cc_df.groupby("source"): | ||
| edge_cycles: dict[tuple, list[float]] = defaultdict(list) | ||
|
|
||
| for _, row in source_cc_df.iterrows(): | ||
| cycle = list(row["cycle"]) | ||
| cc_per_edge = row["cc_per_edge (kcal/mol)"] | ||
| for i, lig in enumerate(cycle): | ||
| lig_a = lig | ||
| lig_b = cycle[i + 1] if i < len(cycle) - 1 else cycle[0] | ||
| edge = self._canonical_edge((lig_a, lig_b)) | ||
| edge_cycles[edge].append(cc_per_edge) | ||
|
|
||
| for (a, b), ccs in edge_cycles.items(): | ||
| rows.append( | ||
| { | ||
| "source": source, | ||
| "ligandA": a, | ||
| "ligandB": b, | ||
| "n_cycles": len(ccs), | ||
| "mean_cc_per_edge (kcal/mol)": sum(ccs) / len(ccs), | ||
| "max_cc_per_edge (kcal/mol)": max(ccs), | ||
| } | ||
| ) | ||
|
|
||
| return ( | ||
| pd.DataFrame(rows) | ||
| .sort_values(["source", "mean_cc_per_edge (kcal/mol)"], ascending=[True, False]) | ||
| .reset_index(drop=True) | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.