polyform-lattice 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polyform/__init__.py +49 -0
- polyform/cli.py +107 -0
- polyform/core.py +289 -0
- polyform/support_mode.py +848 -0
- polyform/topology.py +1466 -0
- polyform/weight_mode.py +972 -0
- polyform_lattice-0.1.0.dist-info/METADATA +172 -0
- polyform_lattice-0.1.0.dist-info/RECORD +11 -0
- polyform_lattice-0.1.0.dist-info/WHEEL +4 -0
- polyform_lattice-0.1.0.dist-info/entry_points.txt +2 -0
- polyform_lattice-0.1.0.dist-info/licenses/LICENSE +21 -0
polyform/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Publishes on every pushed tag that looks like a version, e.g. v0.1.0
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
tags:
|
|
7
|
+
- "v*"
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
name: Build distributions
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.12"
|
|
19
|
+
|
|
20
|
+
- name: Build sdist and wheel
|
|
21
|
+
run: |
|
|
22
|
+
python -m pip install --upgrade build
|
|
23
|
+
python -m build
|
|
24
|
+
|
|
25
|
+
- name: Check metadata
|
|
26
|
+
run: |
|
|
27
|
+
python -m pip install --upgrade twine
|
|
28
|
+
python -m twine check dist/*
|
|
29
|
+
|
|
30
|
+
- uses: actions/upload-artifact@v4
|
|
31
|
+
with:
|
|
32
|
+
name: dist
|
|
33
|
+
path: dist/
|
|
34
|
+
|
|
35
|
+
publish:
|
|
36
|
+
name: Publish to PyPI
|
|
37
|
+
needs: build
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
environment: pypi
|
|
40
|
+
permissions:
|
|
41
|
+
id-token: write # required for Trusted Publishing (OIDC); no API token needed
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/download-artifact@v4
|
|
44
|
+
with:
|
|
45
|
+
name: dist
|
|
46
|
+
path: dist/
|
|
47
|
+
|
|
48
|
+
- name: Publish
|
|
49
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
polyform/cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for PolyForm.
|
|
3
|
+
|
|
4
|
+
polyform support --csv proteoforms.csv --outdir out_support
|
|
5
|
+
polyform weight --csv proteoforms.csv --outdir out_weight
|
|
6
|
+
polyform topology --xlsx atlas.xlsx --outdir out_topology
|
|
7
|
+
|
|
8
|
+
Each subcommand runs the corresponding analysis, writes CSVs (and, unless
|
|
9
|
+
``--no-figures`` is passed, figures) to ``--outdir``, and prints a short
|
|
10
|
+
summary of the result tables.
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _summarise(results):
|
|
19
|
+
for key, df in results.items():
|
|
20
|
+
try:
|
|
21
|
+
print(f" {key:28s} {len(df):>8d} rows x {len(df.columns):>3d} cols")
|
|
22
|
+
except Exception:
|
|
23
|
+
print(f" {key:28s} {type(df).__name__}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv=None):
|
|
27
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
28
|
+
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="polyform",
|
|
31
|
+
description="Structured analysis of proteoform distributions on modal lattices.",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument("--version", action="version",
|
|
34
|
+
version=f"polyform {__version__}")
|
|
35
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
36
|
+
|
|
37
|
+
# support --------------------------------------------------------------
|
|
38
|
+
p_sup = sub.add_parser("support", help="global support-mode structural metrics")
|
|
39
|
+
p_sup.add_argument("--csv", default="proteoforms.csv",
|
|
40
|
+
help="proteoform catalogue CSV (default: proteoforms.csv)")
|
|
41
|
+
p_sup.add_argument("--fasta", default=None,
|
|
42
|
+
help="optional UniProt FASTA to resolve lengths")
|
|
43
|
+
p_sup.add_argument("--pos-base", type=int, default=0,
|
|
44
|
+
help="set to 1 if RESID positions are 1-based")
|
|
45
|
+
p_sup.add_argument("--outdir", default="polyform_support_outputs")
|
|
46
|
+
p_sup.add_argument("--dpi", type=int, default=300)
|
|
47
|
+
p_sup.add_argument("--no-figures", action="store_true")
|
|
48
|
+
|
|
49
|
+
# weight ---------------------------------------------------------------
|
|
50
|
+
p_w = sub.add_parser("weight", help="demonstration occupancy metrics")
|
|
51
|
+
p_w.add_argument("--csv", default="proteoforms.csv")
|
|
52
|
+
p_w.add_argument("--fasta", default=None)
|
|
53
|
+
p_w.add_argument("--pos-base", type=int, default=0)
|
|
54
|
+
p_w.add_argument("--outdir", default="polyform_02_demo_occupancy_outputs")
|
|
55
|
+
p_w.add_argument("--select-n", type=int, default=8)
|
|
56
|
+
p_w.add_argument("--min-states", type=int, default=5)
|
|
57
|
+
p_w.add_argument("--min-delta-k", type=int, default=3)
|
|
58
|
+
p_w.add_argument("--dpi", type=int, default=300)
|
|
59
|
+
p_w.add_argument("--seed", type=int, default=7)
|
|
60
|
+
p_w.add_argument("--no-figures", action="store_true")
|
|
61
|
+
|
|
62
|
+
# topology -------------------------------------------------------------
|
|
63
|
+
p_t = sub.add_parser("topology", help="tissue-resolved support topology")
|
|
64
|
+
p_t.add_argument("--xlsx", default="pr2c00034_si_002 (1).xlsx",
|
|
65
|
+
help="multi-tissue atlas spreadsheet")
|
|
66
|
+
p_t.add_argument("--outdir", default="polyform_03_tissue_support_topology_outputs")
|
|
67
|
+
p_t.add_argument("--sheet", default="All_Tissues")
|
|
68
|
+
p_t.add_argument("--no-group-by-sequence", action="store_true")
|
|
69
|
+
p_t.add_argument("--no-k0-anchor", action="store_true")
|
|
70
|
+
p_t.add_argument("--dpi", type=int, default=300)
|
|
71
|
+
p_t.add_argument("--seed", type=int, default=7)
|
|
72
|
+
p_t.add_argument("--no-figures", action="store_true")
|
|
73
|
+
|
|
74
|
+
args = parser.parse_args(argv)
|
|
75
|
+
|
|
76
|
+
if args.command == "support":
|
|
77
|
+
from .support_mode import run_support_mode
|
|
78
|
+
results = run_support_mode(
|
|
79
|
+
csv=args.csv, fasta_path=args.fasta, pos_base=args.pos_base,
|
|
80
|
+
outdir=args.outdir, make_figures=not args.no_figures, dpi=args.dpi,
|
|
81
|
+
)
|
|
82
|
+
elif args.command == "weight":
|
|
83
|
+
from .weight_mode import run_weight_mode
|
|
84
|
+
results = run_weight_mode(
|
|
85
|
+
csv=args.csv, fasta_path=args.fasta, pos_base=args.pos_base,
|
|
86
|
+
outdir=args.outdir, select_n=args.select_n, min_states=args.min_states,
|
|
87
|
+
min_delta_k=args.min_delta_k, make_figures=not args.no_figures,
|
|
88
|
+
dpi=args.dpi, random_seed=args.seed,
|
|
89
|
+
)
|
|
90
|
+
elif args.command == "topology":
|
|
91
|
+
from .topology import run_topology
|
|
92
|
+
results = run_topology(
|
|
93
|
+
xlsx=args.xlsx, outdir=args.outdir, sheet_name=args.sheet,
|
|
94
|
+
group_by_sequence=not args.no_group_by_sequence,
|
|
95
|
+
add_k0_anchor=not args.no_k0_anchor,
|
|
96
|
+
make_figures=not args.no_figures, dpi=args.dpi, random_seed=args.seed,
|
|
97
|
+
)
|
|
98
|
+
else: # pragma: no cover
|
|
99
|
+
parser.error(f"unknown command {args.command!r}")
|
|
100
|
+
|
|
101
|
+
print(f"\nResult tables (written to {args.outdir}):")
|
|
102
|
+
_summarise(results)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|
polyform/core.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""
|
|
2
|
+
polyform.core
|
|
3
|
+
=============
|
|
4
|
+
|
|
5
|
+
Pure, stateless primitives shared across PolyForm's analyses, exposed as a
|
|
6
|
+
convenience API. These functions are copied verbatim from the manuscript
|
|
7
|
+
analysis scripts so that library use and manuscript reproduction stay identical.
|
|
8
|
+
|
|
9
|
+
Nothing here reads global state or performs I/O beyond the explicit file
|
|
10
|
+
readers (`find_csv`, `read_fasta`).
|
|
11
|
+
"""
|
|
12
|
+
import os
|
|
13
|
+
import glob
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from math import log10, lgamma
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"find_csv", "read_fasta", "parse_ptms", "log10_comb",
|
|
21
|
+
"log10sumexp_base10", "safe_log10_state_fraction", "tuple_to_string",
|
|
22
|
+
"state_to_string", "hamming_distance_state", "normalized_unevenness",
|
|
23
|
+
"shannon_entropy", "normalized_entropy",
|
|
24
|
+
"connected_components_for_states", "component_distance",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
def find_csv(path):
|
|
28
|
+
if os.path.exists(path):
|
|
29
|
+
return path
|
|
30
|
+
|
|
31
|
+
candidates = []
|
|
32
|
+
candidates += glob.glob("*.csv")
|
|
33
|
+
candidates += glob.glob("/content/*.csv")
|
|
34
|
+
|
|
35
|
+
proteoform_candidates = [
|
|
36
|
+
c for c in candidates
|
|
37
|
+
if "proteoform" in os.path.basename(c).lower()
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
if proteoform_candidates:
|
|
41
|
+
return sorted(proteoform_candidates)[0]
|
|
42
|
+
|
|
43
|
+
if candidates:
|
|
44
|
+
return sorted(candidates)[0]
|
|
45
|
+
|
|
46
|
+
raise FileNotFoundError(
|
|
47
|
+
"No CSV file found. Upload proteoforms.csv or set CSV to the correct path."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_fasta(path):
|
|
52
|
+
"""
|
|
53
|
+
Read a UniProt-style FASTA.
|
|
54
|
+
Returns dictionary accession -> sequence.
|
|
55
|
+
"""
|
|
56
|
+
seqs = {}
|
|
57
|
+
acc = None
|
|
58
|
+
buf = []
|
|
59
|
+
|
|
60
|
+
with open(path) as fh:
|
|
61
|
+
for line in fh:
|
|
62
|
+
line = line.rstrip("\n")
|
|
63
|
+
|
|
64
|
+
if line.startswith(">"):
|
|
65
|
+
if acc is not None:
|
|
66
|
+
seqs[acc] = "".join(buf)
|
|
67
|
+
|
|
68
|
+
header = line[1:]
|
|
69
|
+
parts = header.split("|")
|
|
70
|
+
|
|
71
|
+
if len(parts) >= 2:
|
|
72
|
+
acc = parts[1]
|
|
73
|
+
else:
|
|
74
|
+
acc = header.split()[0]
|
|
75
|
+
|
|
76
|
+
buf = []
|
|
77
|
+
else:
|
|
78
|
+
buf.append(line.strip())
|
|
79
|
+
|
|
80
|
+
if acc is not None:
|
|
81
|
+
seqs[acc] = "".join(buf)
|
|
82
|
+
|
|
83
|
+
return seqs
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_ptms(s, pos_base=0):
|
|
87
|
+
"""
|
|
88
|
+
Parse PTM strings of the form:
|
|
89
|
+
'RESID:55@3|RESID:76@8'
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
[(position, mark_code), ...]
|
|
93
|
+
where position is adjusted by POS_BASE.
|
|
94
|
+
"""
|
|
95
|
+
if pd.isna(s) or not str(s).strip():
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
out = []
|
|
99
|
+
|
|
100
|
+
for tok in str(s).split("|"):
|
|
101
|
+
tok = tok.strip()
|
|
102
|
+
if not tok:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
if tok.startswith("RESID:"):
|
|
106
|
+
body = tok[len("RESID:"):]
|
|
107
|
+
if "@" not in body:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
mark, pos = body.split("@", 1)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
pos = int(pos) - pos_base
|
|
114
|
+
out.append((pos, str(mark)))
|
|
115
|
+
except Exception:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def log10_comb(n, k):
|
|
122
|
+
"""
|
|
123
|
+
log10 binomial coefficient using lgamma for numerical stability.
|
|
124
|
+
"""
|
|
125
|
+
if k < 0 or k > n:
|
|
126
|
+
return -np.inf
|
|
127
|
+
|
|
128
|
+
return (lgamma(n + 1) - lgamma(k + 1) - lgamma(n - k + 1)) / np.log(10)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def log10sumexp_base10(values):
|
|
132
|
+
"""
|
|
133
|
+
log10(sum_i 10^values_i), stable for large logs.
|
|
134
|
+
"""
|
|
135
|
+
values = np.asarray(values, dtype=float)
|
|
136
|
+
values = values[np.isfinite(values)]
|
|
137
|
+
|
|
138
|
+
if len(values) == 0:
|
|
139
|
+
return np.nan
|
|
140
|
+
|
|
141
|
+
m = np.max(values)
|
|
142
|
+
return m + log10(np.sum(10 ** (values - m)))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def safe_log10_state_fraction(n_acc, L):
|
|
146
|
+
"""
|
|
147
|
+
log10(n_acc / 2^L), avoiding overflow/underflow.
|
|
148
|
+
"""
|
|
149
|
+
if n_acc <= 0 or L <= 0:
|
|
150
|
+
return np.nan
|
|
151
|
+
|
|
152
|
+
return log10(n_acc) - L * LOG10_2
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def tuple_to_string(t):
|
|
156
|
+
if isinstance(t, tuple):
|
|
157
|
+
return ";".join(map(str, t))
|
|
158
|
+
return str(t)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def state_to_string(state):
|
|
162
|
+
if len(state) == 0:
|
|
163
|
+
return "0^L"
|
|
164
|
+
return ",".join(map(str, state))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def hamming_distance_state(a, b):
|
|
168
|
+
"""
|
|
169
|
+
Hamming distance between binary states encoded as tuples of modified positions.
|
|
170
|
+
"""
|
|
171
|
+
return len(set(a).symmetric_difference(set(b)))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def normalized_unevenness(rho):
|
|
175
|
+
"""
|
|
176
|
+
U_state = 0 when weights are even across accessed states.
|
|
177
|
+
U_state = 1 when all weight is concentrated into one state.
|
|
178
|
+
|
|
179
|
+
rho must sum to 1 over accessed states.
|
|
180
|
+
"""
|
|
181
|
+
rho = np.asarray(rho, dtype=float)
|
|
182
|
+
rho = rho[rho > 0]
|
|
183
|
+
|
|
184
|
+
n = len(rho)
|
|
185
|
+
|
|
186
|
+
if n == 0:
|
|
187
|
+
return np.nan
|
|
188
|
+
if n == 1:
|
|
189
|
+
return 1.0
|
|
190
|
+
|
|
191
|
+
q = rho / rho.sum()
|
|
192
|
+
concentration = np.sum(q ** 2)
|
|
193
|
+
|
|
194
|
+
return (concentration - 1 / n) / (1 - 1 / n)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def shannon_entropy(rho):
|
|
198
|
+
"""
|
|
199
|
+
Statistical entropy of a discrete occupancy vector.
|
|
200
|
+
Not physical entropy.
|
|
201
|
+
"""
|
|
202
|
+
rho = np.asarray(rho, dtype=float)
|
|
203
|
+
rho = rho[rho > 0]
|
|
204
|
+
|
|
205
|
+
if len(rho) == 0:
|
|
206
|
+
return np.nan
|
|
207
|
+
|
|
208
|
+
return -np.sum(rho * np.log(rho))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def normalized_entropy(rho):
|
|
212
|
+
"""
|
|
213
|
+
Entropy normalized by log(number of accessed states).
|
|
214
|
+
Returns 1 for an even distribution over n>1 states.
|
|
215
|
+
"""
|
|
216
|
+
rho = np.asarray(rho, dtype=float)
|
|
217
|
+
rho = rho[rho > 0]
|
|
218
|
+
n = len(rho)
|
|
219
|
+
|
|
220
|
+
if n == 0:
|
|
221
|
+
return np.nan
|
|
222
|
+
if n == 1:
|
|
223
|
+
return 0.0
|
|
224
|
+
|
|
225
|
+
return shannon_entropy(rho) / np.log(n)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def connected_components_for_states(states):
|
|
229
|
+
"""
|
|
230
|
+
Compute connected components of observed support under Hamming adjacency.
|
|
231
|
+
|
|
232
|
+
States are tuples of modified positions.
|
|
233
|
+
Edge exists when Hamming distance = 1.
|
|
234
|
+
|
|
235
|
+
For selected proteins, pairwise computation is transparent and sufficient.
|
|
236
|
+
"""
|
|
237
|
+
states = list(states)
|
|
238
|
+
n = len(states)
|
|
239
|
+
|
|
240
|
+
parent = list(range(n))
|
|
241
|
+
|
|
242
|
+
def find(a):
|
|
243
|
+
while parent[a] != a:
|
|
244
|
+
parent[a] = parent[parent[a]]
|
|
245
|
+
a = parent[a]
|
|
246
|
+
return a
|
|
247
|
+
|
|
248
|
+
def union(a, b):
|
|
249
|
+
ra, rb = find(a), find(b)
|
|
250
|
+
if ra != rb:
|
|
251
|
+
parent[rb] = ra
|
|
252
|
+
|
|
253
|
+
state_sets = [set(s) for s in states]
|
|
254
|
+
|
|
255
|
+
for i in range(n):
|
|
256
|
+
for j in range(i + 1, n):
|
|
257
|
+
if len(state_sets[i].symmetric_difference(state_sets[j])) == 1:
|
|
258
|
+
union(i, j)
|
|
259
|
+
|
|
260
|
+
groups = defaultdict(list)
|
|
261
|
+
for i, s in enumerate(states):
|
|
262
|
+
groups[find(i)].append(s)
|
|
263
|
+
|
|
264
|
+
comps = list(groups.values())
|
|
265
|
+
|
|
266
|
+
# Sort components by size descending, then minimum grade.
|
|
267
|
+
comps = sorted(comps, key=lambda c: (-len(c), min(len(x) for x in c)))
|
|
268
|
+
|
|
269
|
+
comp_map = {}
|
|
270
|
+
for cid, comp in enumerate(comps, start=1):
|
|
271
|
+
for s in comp:
|
|
272
|
+
comp_map[s] = cid
|
|
273
|
+
|
|
274
|
+
return comps, comp_map
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def component_distance(comp_a, comp_b):
|
|
278
|
+
"""
|
|
279
|
+
Minimum Hamming distance between two components.
|
|
280
|
+
"""
|
|
281
|
+
best = np.inf
|
|
282
|
+
|
|
283
|
+
for a in comp_a:
|
|
284
|
+
for b in comp_b:
|
|
285
|
+
d = hamming_distance_state(a, b)
|
|
286
|
+
if d < best:
|
|
287
|
+
best = d
|
|
288
|
+
|
|
289
|
+
return best
|