Skip to content

Commit 2e9a75d

Browse files
authored
Merge pull request #132 from MannLabs/add_intensity_summarization
Add intensity summarization module
2 parents 977cc95 + c5b30a6 commit 2e9a75d

2 files changed

Lines changed: 596 additions & 0 deletions

File tree

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
"""Intensity summarization for hierarchical ion trees.
2+
3+
Sums base-ion intensities at specified tree node types before differential
4+
analysis. For example, specifying ``["frgion"]`` will sum all individual
5+
fragment ions under each fragment-ion group into a single intensity per
6+
replicate, while leaving MS1-isotope and precursor base ions untouched.
7+
8+
When the requested summarization level is above the ion-type level
9+
(e.g. ``"mod_seq_charge"`` or ``"seq"``), leaves are split by their
10+
ion-type ancestor so that fragment and MS1 intensities are never mixed.
11+
"""
12+
13+
import re
14+
from collections import defaultdict
15+
16+
import anytree
17+
import numpy as np
18+
import pandas as pd
19+
20+
from alphaquant.cluster.cluster_ions import REGEX_FRGIONS_ISOTOPES, LEVEL_NAMES
21+
22+
import alphaquant.config.config as aqconfig
23+
import logging
24+
25+
aqconfig.setup_logging()
26+
LOGGER = logging.getLogger(__name__)
27+
28+
ION_TYPE_NODES = {"frgion", "ms1_isotopes", "precursor"}
29+
30+
# Appended to an ion_type node name so the result is a valid base-ion name
31+
# that the downstream tree builder can parse back into the hierarchy.
32+
_NODE_TYPE_TO_COMPLETION_SUFFIX = {
33+
"frgion": "ION_SUM",
34+
"ms1_isotopes": "ISOTOPES_SUM",
35+
"precursor": "URSOR_SUM",
36+
}
37+
38+
# When summarising above ion_type level we insert a synthetic path fragment
39+
# between the higher-level node name and the ion-type suffix.
40+
_LEVEL_TO_SYNTHETIC_INFIX = {
41+
"mod_seq_charge": "_",
42+
"mod_seq": "CHARGE_0_",
43+
"seq": "SUM_CHARGE_0_",
44+
}
45+
46+
_ION_TYPE_TO_FULL_SUFFIX = {
47+
"frgion": "FRGION_SUM",
48+
"ms1_isotopes": "MS1ISOTOPES_SUM",
49+
"precursor": "PRECURSOR_SUM",
50+
}
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# Tree construction (lightweight, from ion-name strings only)
55+
# ---------------------------------------------------------------------------
56+
57+
def build_tree_from_ion_names(protein_name, ion_names):
58+
"""Build a hierarchical tree from base-ion name strings.
59+
60+
Uses the same regex logic as
61+
:func:`~alphaquant.cluster.cluster_ions.create_hierarchical_ion_grouping`
62+
but operates on plain strings rather than ``DifferentialIon`` objects.
63+
"""
64+
nodes = [
65+
anytree.Node(name, type="base", level="base")
66+
for name in ion_names
67+
]
68+
69+
for level_idx, level_patterns in enumerate(REGEX_FRGIONS_ISOTOPES):
70+
name2node = {}
71+
for pattern, node_type in level_patterns:
72+
for node in nodes:
73+
m = re.match(pattern, node.name)
74+
if m:
75+
matching_name = m.group(1)
76+
if matching_name not in name2node:
77+
name2node[matching_name] = anytree.Node(
78+
matching_name,
79+
type=node_type,
80+
level=LEVEL_NAMES[level_idx],
81+
)
82+
node.parent = name2node[matching_name]
83+
if name2node:
84+
nodes = list(name2node.values())
85+
86+
root = anytree.Node(protein_name, type="gene", level="gene")
87+
for node in nodes:
88+
node.parent = root
89+
return root
90+
91+
92+
# ---------------------------------------------------------------------------
93+
# Naming helpers
94+
# ---------------------------------------------------------------------------
95+
96+
def _make_summarized_name_for_ion_type_node(node):
97+
"""Parseable summarized name for a node at the ion_type level."""
98+
return node.name + _NODE_TYPE_TO_COMPLETION_SUFFIX[node.type]
99+
100+
101+
def _make_summarized_name_for_higher_node(parent_node, ion_type):
102+
"""Parseable summarized name when summarising above ion_type level.
103+
104+
Inserts a synthetic path fragment so that the downstream tree builder
105+
can still parse the resulting name into the full hierarchy.
106+
"""
107+
infix = _LEVEL_TO_SYNTHETIC_INFIX[parent_node.level]
108+
suffix = _ION_TYPE_TO_FULL_SUFFIX[ion_type]
109+
return parent_node.name + infix + suffix
110+
111+
112+
# ---------------------------------------------------------------------------
113+
# Grouping logic
114+
# ---------------------------------------------------------------------------
115+
116+
def compute_summarization_groups(pep2prot, ion_names, summarization_nodes):
117+
"""Determine which base ions to group and what to name the summaries.
118+
119+
Args:
120+
pep2prot: dict mapping ion name -> protein name.
121+
ion_names: iterable of all base-ion names present in either condition.
122+
summarization_nodes: list of node types to summarize
123+
(e.g. ``["frgion"]``).
124+
125+
Returns:
126+
groups: list of ``(new_name, [leaf_ion_names], protein)`` tuples.
127+
remaining: set of ion names that stay as individual rows.
128+
"""
129+
if not summarization_nodes:
130+
return [], set(ion_names)
131+
132+
prot2ions = defaultdict(list)
133+
for ion in ion_names:
134+
prot = pep2prot.get(ion)
135+
if prot is not None:
136+
prot2ions[prot].append(ion)
137+
138+
groups = []
139+
summarized_ions = set()
140+
141+
for prot, ions in prot2ions.items():
142+
tree = build_tree_from_ion_names(prot, ions)
143+
144+
for node_type in summarization_nodes:
145+
target_nodes = anytree.findall(
146+
tree, filter_=lambda n, nt=node_type: n.type == nt
147+
)
148+
149+
for target_node in target_nodes:
150+
if node_type in ION_TYPE_NODES:
151+
leaf_names = [
152+
l.name for l in target_node.leaves if l.type == "base"
153+
]
154+
if leaf_names:
155+
new_name = _make_summarized_name_for_ion_type_node(
156+
target_node
157+
)
158+
groups.append((new_name, leaf_names, prot))
159+
summarized_ions.update(leaf_names)
160+
else:
161+
# Above ion_type: split by ion type to avoid mixing
162+
type_to_leaves = defaultdict(list)
163+
for desc in anytree.PreOrderIter(target_node):
164+
if desc.type in ION_TYPE_NODES:
165+
for leaf in desc.leaves:
166+
if leaf.type == "base":
167+
type_to_leaves[desc.type].append(leaf.name)
168+
for ion_type, leaf_names in type_to_leaves.items():
169+
new_name = _make_summarized_name_for_higher_node(
170+
target_node, ion_type
171+
)
172+
groups.append((new_name, leaf_names, prot))
173+
summarized_ions.update(leaf_names)
174+
175+
remaining = set(ion_names) - summarized_ions
176+
return groups, remaining
177+
178+
179+
# ---------------------------------------------------------------------------
180+
# DataFrame summarization
181+
# ---------------------------------------------------------------------------
182+
183+
def summarize_condition_df(df, groups, remaining_ions):
184+
"""Apply summarization to a per-condition dataframe.
185+
186+
Sums intensities in **linear** space for grouped ions, keeps remaining
187+
ions as-is.
188+
189+
Args:
190+
df: DataFrame with log2 intensities, index = quant_id,
191+
columns = sample names.
192+
groups: list of ``(new_name, [leaf_ion_names], protein)`` tuples.
193+
remaining_ions: set of ion names to keep unchanged.
194+
195+
Returns:
196+
Summarized DataFrame (same column layout, modified index).
197+
"""
198+
parts = []
199+
200+
remaining_in_df = df.index.intersection(remaining_ions)
201+
if len(remaining_in_df) > 0:
202+
parts.append(df.loc[remaining_in_df])
203+
204+
for new_name, leaf_names, _prot in groups:
205+
present = [ion for ion in leaf_names if ion in df.index]
206+
if not present:
207+
continue
208+
subset = df.loc[present]
209+
linear = 2.0 ** subset
210+
summed = linear.sum(axis=0)
211+
all_nan = subset.isna().all(axis=0)
212+
with np.errstate(divide='ignore'):
213+
log2_summed = np.log2(summed)
214+
log2_summed[all_nan] = np.nan
215+
log2_summed.name = new_name
216+
parts.append(log2_summed.to_frame().T)
217+
218+
if not parts:
219+
return pd.DataFrame(columns=df.columns)
220+
221+
return pd.concat(parts)
222+
223+
224+
# ---------------------------------------------------------------------------
225+
# Ion quality filtering per group
226+
# ---------------------------------------------------------------------------
227+
228+
def _filter_group_ions(leaf_names, df_c1, df_c2):
229+
"""Select which leaf ions to include in a summarization group.
230+
231+
Strategy:
232+
1. Keep only ions that have values in ALL replicates of BOTH conditions.
233+
2. If no ion qualifies, pick the single ion with the most non-NaN values
234+
across both conditions.
235+
236+
Returns:
237+
Filtered list of ion names.
238+
"""
239+
present_c1 = [ion for ion in leaf_names if ion in df_c1.index]
240+
present_c2 = [ion for ion in leaf_names if ion in df_c2.index]
241+
present_both = set(present_c1) & set(present_c2)
242+
243+
if not present_both:
244+
all_present = set(present_c1) | set(present_c2)
245+
if not all_present:
246+
return []
247+
best_ion = max(all_present, key=lambda ion: (
248+
df_c1.loc[ion].notna().sum() if ion in df_c1.index else 0
249+
) + (
250+
df_c2.loc[ion].notna().sum() if ion in df_c2.index else 0
251+
))
252+
return [best_ion]
253+
254+
n_cols_c1 = df_c1.shape[1]
255+
n_cols_c2 = df_c2.shape[1]
256+
257+
complete = [
258+
ion for ion in present_both
259+
if df_c1.loc[ion].notna().sum() == n_cols_c1
260+
and df_c2.loc[ion].notna().sum() == n_cols_c2
261+
]
262+
263+
if complete:
264+
return complete
265+
266+
best_ion = max(present_both, key=lambda ion:
267+
df_c1.loc[ion].notna().sum() + df_c2.loc[ion].notna().sum()
268+
)
269+
return [best_ion]
270+
271+
272+
# ---------------------------------------------------------------------------
273+
# Public entry point
274+
# ---------------------------------------------------------------------------
275+
276+
def apply_summarization(df_c1, df_c2, pep2prot, summarization_nodes):
277+
"""Summarize ion intensities at specified tree levels.
278+
279+
This is the main entry point called from
280+
:func:`~alphaquant.diffquant.condpair_analysis.analyze_condpair`.
281+
282+
Args:
283+
df_c1: Per-condition DataFrame for condition 1 (log2 intensities).
284+
df_c2: Per-condition DataFrame for condition 2 (log2 intensities).
285+
pep2prot: dict mapping ion name -> protein name.
286+
summarization_nodes: list of node types to summarize
287+
(e.g. ``["frgion"]``).
288+
289+
Returns:
290+
``(df_c1_new, df_c2_new, pep2prot_new)``
291+
"""
292+
all_ions = set(df_c1.index) | set(df_c2.index)
293+
groups, remaining = compute_summarization_groups(
294+
pep2prot, all_ions, summarization_nodes
295+
)
296+
297+
if not groups:
298+
return df_c1, df_c2, pep2prot
299+
300+
filtered_groups = []
301+
for new_name, leaf_names, prot in groups:
302+
selected = _filter_group_ions(leaf_names, df_c1, df_c2)
303+
if selected:
304+
filtered_groups.append((new_name, selected, prot))
305+
306+
if not filtered_groups:
307+
return df_c1, df_c2, pep2prot
308+
309+
df_c1_new = summarize_condition_df(df_c1, filtered_groups, remaining)
310+
df_c2_new = summarize_condition_df(df_c2, filtered_groups, remaining)
311+
312+
pep2prot_new = {ion: pep2prot[ion] for ion in remaining if ion in pep2prot}
313+
for new_name, _leaves, prot in filtered_groups:
314+
pep2prot_new[new_name] = prot
315+
316+
LOGGER.info(
317+
"Summarization at %s: %d base ions -> %d entries "
318+
"(%d summarized groups, %d unchanged)",
319+
summarization_nodes,
320+
len(all_ions),
321+
len(remaining) + len(filtered_groups),
322+
len(filtered_groups),
323+
len(remaining),
324+
)
325+
326+
return df_c1_new, df_c2_new, pep2prot_new

0 commit comments

Comments
 (0)