Skip to content

Commit 32f801f

Browse files
authored
Merge pull request #21 from Image-Analysis-Hub/feature/more-mandatory-cycle-feats
Feature/more mandatory cycle feats - Add cycle_duration as mandatory cycle feature - Make DivisionTime and DivisionRate computation more robust - Rename cell_cycle_completeness feature into cycle_completeness
2 parents e4cae72 + bf3ed4d commit 32f801f

8 files changed

Lines changed: 356 additions & 135 deletions

File tree

notebooks/Managing features.ipynb

Lines changed: 48 additions & 48 deletions
Large diffs are not rendered by default.

pycellin/classes/feature.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ def cells_Feature(provenance: str = "Pycellin") -> Feature:
308308
def cycle_length_Feature(provenance: str = "Pycellin") -> Feature:
309309
feat = Feature(
310310
name="cycle_length",
311-
description="Number of cells in the cell cycle",
311+
description="Number of cells in the cell cycle, minding gaps",
312312
provenance=provenance,
313313
feat_type="node",
314314
lin_type="CycleLineage",
@@ -317,7 +317,17 @@ def cycle_length_Feature(provenance: str = "Pycellin") -> Feature:
317317
return feat
318318

319319

320-
# TODO: define_cycle_duration_Feature
320+
def cycle_duration_Feature(provenance: str = "Pycellin") -> Feature:
321+
feat = Feature(
322+
name="cycle_duration",
323+
description="Number of frames in the cell cycle, regardless of gaps",
324+
provenance=provenance,
325+
feat_type="node",
326+
lin_type="CycleLineage",
327+
data_type="int",
328+
unit="frame",
329+
)
330+
return feat
321331

322332

323333
def level_Feature(provenance: str = "Pycellin") -> Feature:
@@ -574,8 +584,9 @@ def _add_cycle_lineage_features(self) -> None:
574584
feat_ID = cycle_ID_Feature()
575585
feat_cells = cells_Feature()
576586
feat_length = cycle_length_Feature()
587+
feat_duration = cycle_duration_Feature()
577588
feat_level = level_Feature()
578-
for feat in [feat_ID, feat_cells, feat_length, feat_level]:
589+
for feat in [feat_ID, feat_cells, feat_length, feat_duration, feat_level]:
579590
if feat.name not in self.feats_dict:
580591
self._add_feature(feat)
581592
self._protect_feature(feat.name)

pycellin/classes/lineage.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,19 +1218,24 @@ def __init__(self, cell_lineage: CellLineage | None = None) -> None:
12181218
# Adding node and graph features.
12191219
self.graph["lineage_ID"] = cell_lineage.graph["lineage_ID"]
12201220
for n in divs + leaves:
1221+
cells_in_cycle = cell_lineage.get_cell_cycle(n)
1222+
first = cells_in_cycle[0]
1223+
last = cells_in_cycle[-1]
12211224
self.nodes[n]["cycle_ID"] = n
1222-
self.nodes[n]["cells"] = cell_lineage.get_cell_cycle(n)
1223-
self.nodes[n]["cycle_length"] = len(self.nodes[n]["cells"])
1225+
self.nodes[n]["cells"] = cells_in_cycle
1226+
# How many cells in the cycle?
1227+
self.nodes[n]["cycle_length"] = len(cells_in_cycle)
1228+
# How many frames in the cycle?
1229+
self.nodes[n]["cycle_duration"] = (
1230+
cell_lineage.nodes[last]["frame"]
1231+
- cell_lineage.nodes[first]["frame"]
1232+
) + 1
12241233
root = self.get_root()
12251234
if isinstance(root, list):
12261235
raise LineageStructureError(
12271236
"A cycle lineage cannot have multiple roots."
12281237
)
12291238
self.nodes[n]["level"] = nx.shortest_path_length(self, root, n)
1230-
# cell_cycle completeness?
1231-
# div_time?
1232-
# cell cycle duration?
1233-
# Or I add it later with add_custom_feature()?
12341239

12351240
def __str__(self) -> str:
12361241
name_txt = f" named {self.graph['name']}" if "name" in self.graph else ""

pycellin/classes/model.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,7 +1063,7 @@ def add_branch_total_displacement(
10631063
)
10641064
self.add_custom_feature(motion.BranchTotalDisplacement(feat))
10651065

1066-
def add_cell_cycle_completeness(
1066+
def add_cycle_completeness(
10671067
self,
10681068
rename: str | None = None,
10691069
) -> None:
@@ -1083,15 +1083,15 @@ def add_cell_cycle_completeness(
10831083
New name for the feature (default is None).
10841084
"""
10851085
feat = Feature(
1086-
name=rename if rename else "cell_cycle_completeness",
1086+
name=rename if rename else "cycle_completeness",
10871087
description="Completeness of the cell cycle",
10881088
provenance="Pycellin",
10891089
feat_type="node",
10901090
lin_type="CycleLineage",
10911091
data_type="bool",
10921092
unit="none",
10931093
)
1094-
self.add_custom_feature(tracking.CellCycleCompleteness(feat))
1094+
self.add_custom_feature(tracking.CycleCompleteness(feat))
10951095

10961096
def add_cell_displacement(
10971097
self,

pycellin/graph/features/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from .tracking import (
77
AbsoluteAge,
88
RelativeAge,
9-
CellCycleCompleteness,
9+
CycleCompleteness,
1010
DivisionTime,
1111
DivisionRate,
1212
)

pycellin/graph/features/tracking.py

Lines changed: 144 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,8 @@
3030
"""
3131

3232

33-
from pycellin.classes import CellLineage, CycleLineage
34-
from pycellin.classes import Feature
35-
from pycellin.classes import Data
33+
from pycellin.classes import CellLineage, CycleLineage, Data, Feature
34+
from pycellin.classes.exceptions import FusionError
3635
from pycellin.classes.feature_calculator import NodeGlobalFeatureCalculator
3736

3837
# TODO: should I add the word Calc or Calculator to the class names?
@@ -148,7 +147,7 @@ def compute( # type: ignore[override]
148147
return age_in_frame * self.time_step
149148

150149

151-
class CellCycleCompleteness(NodeGlobalFeatureCalculator):
150+
class CycleCompleteness(NodeGlobalFeatureCalculator):
152151
"""
153152
Calculator to compute the cell cycle completeness.
154153
@@ -202,14 +201,97 @@ def compute( # type: ignore[override]
202201
return True
203202

204203

204+
def _get_cell_lin_frames(lineage: CellLineage, noi: int) -> tuple[int, int]:
205+
"""
206+
Get the frames of the divisions defining the cell cycle.
207+
208+
This function is used by the DivisionTime and DivisionRate calculators.
209+
210+
Parameters
211+
----------
212+
lineage : CellLineage
213+
Lineage graph containing the node of interest.
214+
noi : int
215+
Node ID (cell_ID) of the cell of interest.
216+
217+
Returns
218+
-------
219+
tuple[int, int]
220+
Frames of the current and previous division.
221+
222+
Raises
223+
------
224+
KeyError
225+
If the cell is not in the lineage.
226+
FusionError
227+
If the cell has more than one ancestor.
228+
"""
229+
if noi not in lineage.nodes:
230+
raise KeyError(f"Cell {noi} not in the lineage.")
231+
cells = lineage.get_cell_cycle(noi)
232+
frame_current_div = lineage.nodes[cells[-1]]["frame"]
233+
ancestors = list(lineage.predecessors(cells[0]))
234+
if len(ancestors) > 1:
235+
raise FusionError(noi, lineage.graph["lineage_ID"])
236+
elif len(ancestors) == 0:
237+
frame_prev_div = lineage.nodes[cells[0]]["frame"]
238+
else:
239+
frame_prev_div = lineage.nodes[ancestors[0]]["frame"]
240+
return frame_current_div, frame_prev_div
241+
242+
243+
def _get_cycle_lin_frames(
244+
data: Data, lineage: CycleLineage, noi: int
245+
) -> tuple[int, int]:
246+
"""
247+
Get the frames of the divisions defining the cell cycle.
248+
249+
This function is used by the DivisionTime and DivisionRate calculators.
250+
251+
Parameters
252+
----------
253+
data : Data
254+
Data object containing the lineage.
255+
lineage : CycleLineage
256+
Lineage graph containing the node of interest.
257+
noi : int
258+
Node ID (cell_ID) of the cell of interest.
259+
260+
Returns
261+
-------
262+
tuple[int, int]
263+
Frames of the current and previous division.
264+
265+
Raises
266+
------
267+
KeyError
268+
If the cycle is not in the lineage.
269+
FusionError
270+
If the cycle has more than one ancestor.
271+
"""
272+
if noi not in lineage.nodes:
273+
raise KeyError(f"Cycle {noi} not in the lineage.")
274+
cells = lineage.nodes[noi]["cells"]
275+
cell_lin = data.cell_data[lineage.graph["lineage_ID"]]
276+
frame_current_div = cell_lin.nodes[cells[-1]]["frame"]
277+
ancestors = list(lineage.predecessors(noi))
278+
if len(ancestors) > 1:
279+
raise FusionError(noi, lineage.graph["lineage_ID"])
280+
elif len(ancestors) == 0:
281+
frame_prev_div = cell_lin.nodes[cells[0]]["frame"]
282+
else:
283+
prev_cells = lineage.nodes[ancestors[0]]["cells"]
284+
frame_prev_div = cell_lin.nodes[prev_cells[-1]]["frame"]
285+
return frame_current_div, frame_prev_div
286+
287+
205288
class DivisionTime(NodeGlobalFeatureCalculator):
206289
"""
207290
Calculator to compute the division time of cells.
208291
209-
Division time is defined as the time between 2 divisions.
210-
It is also the length of the cell cycle of the cell of interest.
211-
It is given in frames by default, but can be converted
212-
to the time unit of the model if specified.
292+
Division time is defined as the time elapsed between the 2 divisions
293+
that define the cell cycle. It is given in frames by default, but can
294+
be converted to the time unit of the model if specified.
213295
"""
214296

215297
def __init__(self, feature: Feature, time_step: int | float = 1):
@@ -250,14 +332,16 @@ def compute( # type: ignore[override]
250332
If the cell or cycle is not in the lineage.
251333
"""
252334
if isinstance(lineage, CellLineage):
253-
if noi not in lineage.nodes:
254-
raise KeyError(f"Cell {noi} not in the lineage.")
255-
cell_cycle = lineage.get_cell_cycle(noi)
256-
return len(cell_cycle) * self.time_step
335+
frame_curr_div, frame_prev_div = _get_cell_lin_frames(lineage, noi)
257336
elif isinstance(lineage, CycleLineage):
258-
if noi not in lineage.nodes:
259-
raise KeyError(f"Cycle {noi} not in the lineage.")
260-
return lineage.nodes[noi]["cycle_length"] * self.time_step
337+
frame_curr_div, frame_prev_div = _get_cycle_lin_frames(data, lineage, noi)
338+
else:
339+
raise TypeError(
340+
f"Lineage must be of type CellLineage or CycleLineage, "
341+
f"not {type(lineage)}."
342+
)
343+
344+
return (frame_curr_div - frame_prev_div) * self.time_step
261345

262346

263347
class DivisionRate(NodeGlobalFeatureCalculator):
@@ -270,17 +354,29 @@ class DivisionRate(NodeGlobalFeatureCalculator):
270354
to divisions per time unit of the model if specified.
271355
"""
272356

273-
def __init__(self, feature: Feature, time_step: int | float = 1):
357+
def __init__(
358+
self, feature: Feature, time_step: int | float = 1, use_div_time: bool = False
359+
):
274360
"""
275361
Parameters
276362
----------
277363
feature : Feature
278364
Feature object to which the calculator is associated.
279365
time_step : int | float, optional
280366
Time step between 2 frames, in time unit. Default is 1.
367+
use_div_time : bool, optional
368+
If True, use the division time already computed in the lineage.
369+
If False, compute the division time from the lineage. Default is False.
370+
The first option is faster but you need to ensure that the division time
371+
is computed and updated BEFORE division rate. This can be ensured
372+
by adding to the model the division time feature before the division
373+
rate feature. Moreover, if `use_div_time` is True, `time_step` will be
374+
ignored: division rate will use the division time unit (e.g. if division
375+
time is in frames, division rate will be in divisions per frame).
281376
"""
282377
super().__init__(feature)
283378
self.time_step = time_step
379+
self.use_div_time = use_div_time
284380

285381
def compute( # type: ignore[override]
286382
self, data: Data, lineage: CellLineage | CycleLineage, noi: int
@@ -307,15 +403,40 @@ def compute( # type: ignore[override]
307403
KeyError
308404
If the cell or cycle is not in the lineage.
309405
"""
310-
if isinstance(lineage, CellLineage):
406+
if self.use_div_time:
311407
if noi not in lineage.nodes:
312-
raise KeyError(f"Cell {noi} not in the lineage.")
313-
cell_cycle = lineage.get_cell_cycle(noi)
314-
return 1 / (len(cell_cycle) * self.time_step)
408+
if isinstance(lineage, CellLineage):
409+
lin_txt = "Cell"
410+
elif isinstance(lineage, CycleLineage):
411+
lin_txt = "Cycle"
412+
else:
413+
raise TypeError(
414+
f"Lineage must be of type CellLineage or CycleLineage, "
415+
f"not {type(lineage)}."
416+
)
417+
raise KeyError(f"{lin_txt} {noi} not in the lineage.")
418+
try:
419+
div_time = lineage.nodes[noi]["division_time"]
420+
except KeyError:
421+
raise KeyError(
422+
f"Division time not present for cell {noi} in lineage "
423+
f"{lineage.graph['lineage_ID']}."
424+
)
425+
return 1 / div_time
426+
427+
if isinstance(lineage, CellLineage):
428+
frame_curr_div, frame_prev_div = _get_cell_lin_frames(lineage, noi)
315429
elif isinstance(lineage, CycleLineage):
316-
if noi not in lineage.nodes:
317-
raise KeyError(f"Cycle {noi} not in the lineage.")
318-
return 1 / (lineage.nodes[noi]["cycle_length"] * self.time_step)
430+
frame_curr_div, frame_prev_div = _get_cycle_lin_frames(data, lineage, noi)
431+
else:
432+
raise TypeError(
433+
f"Lineage must be of type CellLineage or CycleLineage, "
434+
f"not {type(lineage)}."
435+
)
436+
437+
div_time = (frame_curr_div - frame_prev_div) * self.time_step
438+
div_rate = 1 / div_time
439+
return div_rate
319440

320441

321442
# class CellPhase(NodeGlobalFeatureCalculator):

pycellin/graph/features/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def get_pycellin_cycle_lineage_features() -> dict[str, str]:
4242
"Mean displacement of the cell during the cell cycle"
4343
),
4444
"branch_mean_speed": "Mean speed of the cell during the cell cycle",
45-
"cell_cycle_completeness": (
45+
"cycle_completeness": (
4646
"Completeness of the cell cycle, i.e. does it start and end with a division"
4747
),
4848
"division_time": "Time elapsed between the birth of a cell and its division",

0 commit comments

Comments
 (0)