Skip to content

Commit bc649d6

Browse files
aarmeyAaron Meyer
andauthored
Refactor LineageTree to SciPy CSR sparse array representation and array-level simulation (#1000) (#1002)
* Migrate to uv, ty, and ruff; simplify and fix typing issues * Clean up code duplication across datasets, state censoring, figures, and tree traversal * Optimize test performance: reduce oversized fixtures and seed RNG deterministically * Implement analytical gradient for gamma_estimator * Implement exact 1D profile likelihood solver and PAVA isotonic regression for gamma estimation * Replace linked-list lineage tree with SciPy CSR array representation (#1000) * Refactor tree simulation and censoring to array-based data model * Remove legacy CellVar censoring methods and update documentation --------- Co-authored-by: Aaron Meyer <git@ameyer.me>
1 parent 1c9f37d commit bc649d6

14 files changed

Lines changed: 462 additions & 498 deletions

‎docs/tHMM-getting-started.rst‎

Lines changed: 9 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -13,117 +13,28 @@ This way, we can quantify phenotypic heterogeneity within a population of cells
1313
We first introduce how to synthesize these populations by working up from the basic unit; cells. We then introduce how to synthesize lineages, which are just hierarchical lineage tree groupings of cells based on their family history. Our model ultimately analyzes populations which are groups of one or more lineages that share the same states.
1414

1515

16-
1. Synthesizing Cells
17-
---------------------
16+
1. Lineage Tree Data Model
17+
---------------------------
1818

19-
Users will rarely ever have to create individual cells, but their
20-
functionality is presented so the user is familiar with them. Users will
21-
however, create lineages, and as such, they will have to become
22-
comfortable with creating transition matrices using ``numpy``.
23-
Transition matrices define the probabilities of how cells will divide.
24-
Cells can only be made (new cells as a result of an existing cell
25-
dividing) when given a transition matrix. We provide an example below.
19+
Lineage trees in tHMM are represented at the lineage level using a **SciPy CSR sparse array** (representing parent-to-daughter connectivity) alongside **NumPy arrays** for observations, hidden states, and cell lifetimes.
2620

27-
The transition matrix defines the rate at which cells are likely to
28-
change from one state to another. We use the defintion of a stochastic
29-
transition rate matrix from Wikipedia, that is, the column index defines
30-
the state in which we start, and the row defines the state at which we
31-
end up. That is, if an element at the index of a transition matrix
32-
:math:`T` at row :math:`i` and at column :math:`j`, is defined as
33-
:math:`T_{i,j}`, then
21+
Transition matrices define the probabilities of how cells divide and switch states across generations.
3422

35-
.. math:: T_{i,j} = \mathbb{P}(z = j | z = i).
23+
The transition matrix defines the rate at which cells change from one state to another. Specifically, if an element of a transition matrix :math:`T` at row :math:`i` and column :math:`j` is defined as :math:`T_{i,j}`, then
3624

25+
.. math:: T_{i,j} = \mathbb{P}(z_{\text{daughter}} = j | z_{\text{parent}} = i).
3726

38-
\ Indexing for this matrix and the states starts at :math:`0`. Usually
39-
the number of states is represented as the capital letter :math:`K` and
40-
indexed by the lower-case letter :math:`k`. For most of our examples, we
41-
will deal with two states, i.e., :math:`K=2`.
27+
Indexing for states starts at :math:`0`. Usually the number of states is represented as the capital letter :math:`K` and indexed by :math:`k`. For most examples, we deal with two states, i.e., :math:`K=2`.
4228

4329
.. code:: ipython3
4430
4531
import numpy as np
46-
# The capital letter T will represent the state transition probability matrix.
47-
# This transition matrix, in particular, is the two-dimensional
48-
# identity matrix. Recall that the elements of the transition matrix
49-
# represent the probability of transitioning from one state to another.
50-
T = np.array([[1.0, 0.0],
51-
[0.0, 1.0]], dtype="float")
52-
# This identity matrix implies that there are no transitions between cells of
53-
# state 0 to cells of state 1. It also implies that there are no transitions
54-
# between cells of state 1 to cells of state 0.
55-
# Another transition matrix could be the following:
56-
#
57-
# T = np.array([[0.75, 0.25],
58-
# [0.15, 0.85]], dtype="float")
59-
#
60-
# Note that the rows of the transition matrix have to sum one, because of the
61-
# Law of Total Probability and the Law of Conditional Probability. This will be
62-
# expanded on in a later notebook.
63-
64-
Cells are defined by their state, their relationships to other cells,
65-
and collections of observations. Knowing how to create cells, however,
66-
is not required by the user. It is beneficial to understand how the
67-
``CellVar`` class is designed to create objects that store a ``state``,
68-
its relationships (``left``, ``right``, ``parent``) to other cells, and
69-
its multivariate observations (``obs``). When cells are created via the
70-
``member`` function, their familiar relationships are automatically
71-
assigned. The two daughters are assigned to ``left`` and ``right`` and
72-
for the daughters, the parent is assigned to ``parent``. Other familiar
73-
relationships can be accessed through other member variables and
74-
functions. Note that the first generation is empty (we can see this
75-
because the ``parent_cell`` is instantiated with ``parent=None`` and
76-
with ``gen=1`` which means that ``gen=0`` of the lineage contains
77-
nothing except ``None``). As such, indexing for generations of lineages
78-
starts at ``1``. We will discuss more about the multivariate
79-
observations that cells store later; for now, the following exercises
80-
will focus on the transition matrix and the familiar relationships of
81-
cells.
8232
83-
.. code:: ipython3
84-
85-
import numpy as np
86-
from lineage.CellVar import CellVar as c
87-
88-
parent_cell = c(state=0, parent=None)
33+
# State transition probability matrix
8934
T = np.array([[0.75, 0.25],
9035
[0.15, 0.85]], dtype="float")
91-
left_cell, right_cell = parent_cell.divide(T)
92-
93-
--------------
94-
95-
.. code:: ipython3
96-
97-
print(parent_cell)
98-
# <lineage.CellVar.CellVar object at 0x000001A685C55E10>
99-
print(left_cell, right_cell)
100-
# <lineage.CellVar.CellVar object at 0x000001A6961B3EF0> <lineage.CellVar.CellVar object at 0x000001A69609C978>
101-
print(f"\nThe value of the element at (0,0) of the transition rate matrix is {T[0,0]}")
102-
# The value of the element at (0,0) of the transition rate matrix is 0.75
103-
104-
--------------
105-
106-
The ``gen`` argument for instantiating cells represents the generation
107-
of the cell which start at 1.
108-
109-
110-
--------------
111-
112-
113-
``parent_cell``, ``left_cell``, and ``right_cell`` define a 3-cell
114-
lineage, with 2 generations. The first generation has one cell which was
115-
declared and can be accessed at ``parent_cell``. Calling the member
116-
function ``divide`` on ``parent_cell`` created two new cells which can
117-
be accessed at ``left_cell`` and ``right_cell``. The daughter cells of
118-
any cell can also be accessed using “dot” notation, using the
119-
member variables, ``left`` and ``right``. The division process
120-
utilizes the transition matrix. Our code provides some very basic
121-
printing methods to print out cells. The following code verifies that the ``left_cell`` is actually the left daughter of ``parent_cell``.
122-
123-
.. code:: ipython3
12436
125-
# Use the `is` keyword to compare Python objects.
126-
assert left_cell is parent_cell.left
37+
Note that the rows of the transition matrix must sum to 1 by the Law of Total Probability.
12738

12839
--------------
12940

‎lineage/BaumWelch.py‎

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,12 @@ def do_E_step(tHMMobj: tHMM) -> tuple[list, list, list, list]:
2828
EL = get_Emission_Likelihoods(tHMMobj.X, tHMMobj.estimate.E)
2929

3030
for ii, lO in enumerate(tHMMobj.X):
31-
MSD.append(get_MSD(lO.cell_to_daughters, tHMMobj.estimate.pi, tHMMobj.estimate.T))
31+
MSD.append(get_MSD(lO.tree, tHMMobj.estimate.pi, tHMMobj.estimate.T))
3232

33-
NF_one, beta = get_beta_and_NF(lO.leaves_idx, lO.cell_to_daughters, tHMMobj.estimate.T, MSD[ii], EL[ii])
33+
NF_one, beta = get_beta_and_NF(lO.leaves_idx, lO.tree, tHMMobj.estimate.T, MSD[ii], EL[ii])
3434
NF.append(NF_one)
3535
betas.append(beta)
36-
gammas.append(get_gamma(lO.cell_to_daughters, tHMMobj.estimate.T, MSD[ii], betas[ii]))
36+
gammas.append(get_gamma(lO.tree, tHMMobj.estimate.T, MSD[ii], betas[ii]))
3737

3838
return MSD, NF, betas, gammas
3939

@@ -165,8 +165,7 @@ def do_M_T_step(
165165
for num, lO in enumerate(tt.X):
166166
# local T estimate
167167
numer_e += get_all_zetas(
168-
lO.leaves_idx,
169-
lO.cell_to_daughters,
168+
lO.tree,
170169
betas[i][num],
171170
MSD[i][num],
172171
gammas[i][num],
@@ -193,8 +192,7 @@ def do_M_E_step(tHMMobj: tHMM, gammas: list[np.ndarray]):
193192
:type tHMMobj: object
194193
:param gammas: gamma values. The conditional probability of states, given the observation of the whole tree
195194
"""
196-
all_cells = [cell.obs for lineage in tHMMobj.X for cell in lineage.output_lineage]
197-
cell_arr = np.array(all_cells)
195+
cell_arr = np.vstack([lineage.obs for lineage in tHMMobj.X])
198196
all_gammas = np.vstack(gammas)
199197
for state_j in range(tHMMobj.num_states):
200198
tHMMobj.estimate.E[state_j].estimator(cell_arr, all_gammas[:, state_j])
@@ -206,24 +204,19 @@ def do_M_E_step_atonce(all_tHMMobj: list[tHMM], all_gammas: list[list[np.ndarray
206204
After reshaping, we will have a list of lists for each state.
207205
This function is specifically written for the experimental data of G1 and S-G2 cell cycle fates and durations.
208206
"""
209-
gms = []
210-
for gm in all_gammas:
211-
gms.append(np.vstack(gm))
207+
gms = [np.vstack(gm) for gm in all_gammas]
212208

213-
all_cells = np.array([cell.obs for lineage in all_tHMMobj[0].X for cell in lineage.output_lineage])
214-
if len(all_cells[1, :]) == 6:
215-
phase = True
216-
else:
217-
phase = False
209+
all_cells_0 = np.vstack([lineage.obs for lineage in all_tHMMobj[0].X])
210+
phase = all_cells_0.shape[1] == 6
218211

219212
G1cells = []
220213
G2cells = []
221214
cells = []
222215
for tHMMobj in all_tHMMobj:
223-
all_cells = np.array([cell.obs for lineage in tHMMobj.X for cell in lineage.output_lineage])
216+
all_cells = np.vstack([lineage.obs for lineage in tHMMobj.X])
224217
if phase:
225-
G1cells.append(all_cells[:, np.array([0, 2, 4])])
226-
G2cells.append(all_cells[:, np.array([1, 3, 5])])
218+
G1cells.append(all_cells[:, [0, 2, 4]])
219+
G2cells.append(all_cells[:, [1, 3, 5]])
227220
else:
228221
cells.append(all_cells)
229222

‎lineage/HMM/E_step.py‎

Lines changed: 32 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import numpy as np
22
import numpy.typing as npt
3-
from numba import jit
3+
from scipy.sparse import csr_array
44

55

66
def get_MSD(
7-
cell_to_daughters: np.ndarray,
7+
tree: csr_array,
88
pi: npt.NDArray[np.float64],
99
T: npt.NDArray[np.float64],
1010
) -> npt.NDArray[np.float64]:
@@ -25,27 +25,34 @@ def get_MSD(
2525
2626
:math:`P(z_u = k) = \sum_j(Transition(j -> k) * P(parent_{cell_u}) = j)`
2727
28+
:param tree: CSR array representing the lineage tree adjacency
2829
:param pi: Initial probabilities vector
2930
:param T: State transitions matrix
3031
:return: The marginal state distribution
3132
"""
32-
m = np.zeros((cell_to_daughters.shape[0], pi.size))
33+
m = np.zeros((tree.shape[0], pi.size))
3334
m[0, :] = pi
3435

3536
# recursion based on parent cell
36-
for pIDX, cIDX in enumerate(cell_to_daughters):
37-
if cIDX[0] != -1:
38-
m[cIDX[0], :] = m[pIDX, :] @ T
39-
if cIDX[1] != -1:
40-
m[cIDX[1], :] = m[pIDX, :] @ T
37+
for pIDX in range(tree.shape[0]):
38+
start, end = tree.indptr[pIDX], tree.indptr[pIDX + 1]
39+
if start < end:
40+
p_trans = m[pIDX, :] @ T
41+
for cIDX in tree.indices[start:end]:
42+
m[cIDX, :] = p_trans
4143

4244
# Assert all ~= 1.0
4345
assert np.linalg.norm(np.sum(m, axis=1) - 1.0) < 1e-9
4446
return m
4547

4648

47-
@jit
48-
def get_beta_and_NF(leaves_idx, cell_to_daughters, T: np.ndarray, MSD: np.ndarray, EL: np.ndarray):
49+
def get_beta_and_NF(
50+
leaves_idx: np.ndarray,
51+
tree: csr_array,
52+
T: np.ndarray,
53+
MSD: np.ndarray,
54+
EL: np.ndarray,
55+
) -> tuple[np.ndarray, np.ndarray]:
4956
r"""
5057
Normalizing factor (NF) matrix and base case at the leaves.
5158
@@ -54,50 +61,9 @@ def get_beta_and_NF(leaves_idx, cell_to_daughters, T: np.ndarray, MSD: np.ndarra
5461
This normalizing factor is essentially the marginal
5562
observation distribution for a node.
5663
57-
This function gets the normalizing factor for
58-
the upward recursion only for the leaves.
59-
We first calculate the joint probability
60-
using the definition of conditional probability:
61-
62-
:math:`P(x_n = x | z_n = k) * P(z_n = k) = P(x_n = x , z_n = k)`,
63-
where n are the leaf nodes.
64-
65-
We can then sum this joint probability over k,
66-
which are the possible states z_n can be,
67-
and through the law of total probability,
68-
obtain the marginal observation distribution
69-
:math:`P(x_n = x) = sum_k ( P(x_n = x , z_n = k) ) = P(x_n = x)`.
70-
71-
Beta matrix and base case at the leaves.
72-
73-
Each element in this N by K matrix is the beta value
74-
for each cell and at each state. In particular, this
75-
value is derived from the Marginal State Distributions
76-
(MSD), the Emission Likelihoods (EL), and the
77-
Normalizing Factors (NF). Each beta value
78-
for the leaves is exactly the probability
79-
80-
:math:`beta[n,k] = P(z_n = k | x_n = x)`.
81-
82-
Using Bayes Theorem, we see that the above equals
83-
84-
numerator = :math:`P(x_n = x | z_n = k) * P(z_n = k)`
85-
denominator = :math:`P(x_n = x)`
86-
:math:`beta[n,k] = numerator / denominator`
87-
88-
For non-leaf cells, the first value in the numerator is the Emission
89-
Likelihoods. The second value in the numerator is
90-
the Marginal State Distributions. The value in the
91-
denominator is the Normalizing Factor.
92-
93-
Traverses upward through each tree and calculates the
94-
beta value for each non-leaf cell. The normalizing factors (NFs)
95-
are also calculated as an intermediate for determining each
96-
beta term. Helper functions are called to determine one of
97-
the terms in the NF equation. This term is also used in the calculation
98-
of the betas.
99-
100-
:param tHMMobj: A class object with properties of the lineages of cells
64+
:param leaves_idx: array of indices corresponding to leaf cells
65+
:param tree: CSR array representing the lineage tree adjacency
66+
:param T: Transition probability matrix
10167
:param MSD: The marginal state distribution P(z_n = k)
10268
:param EL: The emissions likelihood
10369
:return: normalizing factor. The marginal observation distribution P(x_n = x)
@@ -108,32 +74,23 @@ def get_beta_and_NF(leaves_idx, cell_to_daughters, T: np.ndarray, MSD: np.ndarra
10874
ELMSD = EL * MSD
10975

11076
### NF leaf calculation
111-
# P(x_n = x , z_n = k) = P(x_n = x | z_n = k) * P(z_n = k)
112-
# this product is the joint probability
113-
# P(x_n = x) = sum_k ( P(x_n = x , z_n = k) )
114-
# the sum of the joint probabilities is the marginal probability
11577
NF = np.zeros(MSD.shape[0], dtype=float) # instantiating N by 1 array
11678
NF[leaves_idx] = np.sum(ELMSD[leaves_idx, :], axis=1)
11779
assert np.all(np.isfinite(NF))
11880

11981
### beta calculation
12082
beta = np.zeros_like(MSD)
121-
122-
# Emission Likelihood, Marginal State Distribution, Normalizing Factor (same regardless of state)
123-
# P(x_n = x | z_n = k), P(z_n = k), P(x_n = x)
12483
beta[leaves_idx, :] = ELMSD[leaves_idx, :] / NF[leaves_idx, np.newaxis]
12584

12685
# Assert all ~= 1.0
12786
assert np.abs(np.sum(beta[-1]) - 1.0) < 1e-9
12887

129-
cIDXs = np.arange(MSD.shape[0])
130-
cIDXs = np.delete(cIDXs, leaves_idx)
131-
cIDXs = np.flip(cIDXs)
88+
cIDXs = np.nonzero(np.diff(tree.indptr) > 0)[0][::-1]
13289

13390
for pii in cIDXs:
134-
ch_ii = cell_to_daughters[pii, :]
91+
ch_ii = tree.indices[tree.indptr[pii] : tree.indptr[pii + 1]]
13592
ratt = (beta[ch_ii, :] / MSD_array[ch_ii, :]) @ T.T
136-
fac1 = ratt[0, :] * ratt[1, :] * ELMSD[pii, :]
93+
fac1 = np.prod(ratt, axis=0) * ELMSD[pii, :]
13794

13895
NF[pii] = np.sum(fac1)
13996
beta[pii, :] = fac1 / NF[pii]
@@ -142,7 +99,7 @@ def get_beta_and_NF(leaves_idx, cell_to_daughters, T: np.ndarray, MSD: np.ndarra
14299

143100

144101
def get_gamma(
145-
cell_to_daughters: npt.NDArray[np.uintp],
102+
tree: csr_array,
146103
T: npt.NDArray[np.float64],
147104
MSD: npt.NDArray[np.float64],
148105
beta: npt.NDArray[np.float64],
@@ -151,11 +108,11 @@ def get_gamma(
151108
Get the gammas using downward recursion from the root nodes.
152109
The conditional probability of states, given observation of the whole tree P(z_n = k | X_bar = x_bar)
153110
x_bar is the observations for the whole tree.
154-
gamma_1 (k) = P(z_1 = k | X_bar = x_bar)
155-
gamma_n (k) = P(z_n = k | X_bar = x_bar)
156111
112+
:param tree: CSR array representing the lineage tree adjacency
113+
:param T: State transitions matrix
157114
:param MSD: The marginal state distribution P(z_n = k)
158-
:param betas: beta values. The conditional probability of states, given observations of the sub-tree rooted in cell_n
115+
:param beta: beta values. The conditional probability of states, given observations of the sub-tree rooted in cell_n
159116
"""
160117
gamma = np.zeros_like(beta)
161118
gamma[0, :] = beta[0, :]
@@ -165,14 +122,11 @@ def get_gamma(
165122
coeffs = np.maximum(coeffs, epss)
166123
beta_parents = T @ coeffs.T
167124

168-
# Getting lineage by generation, but it is sorted this way
169-
for pidx, cis in enumerate(cell_to_daughters):
170-
for ci in cis:
171-
if ci == -1:
172-
continue
173-
174-
A = gamma[pidx, :].T / beta_parents[:, ci]
175-
125+
non_leaves = np.nonzero(np.diff(tree.indptr) > 0)[0]
126+
for pidx in non_leaves:
127+
A_base = gamma[pidx, :].T
128+
for ci in tree.indices[tree.indptr[pidx] : tree.indptr[pidx + 1]]:
129+
A = A_base / beta_parents[:, ci]
176130
gamma[ci, :] = coeffs[ci, :] * (A @ T)
177131

178132
assert np.all(np.isfinite(gamma))

0 commit comments

Comments
 (0)