argile 0.0.1__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.
argile/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from argile.main import *
2
+ from argile.world_map import *
3
+ from argile.likelihood import *
4
+ from argile.locate import *
argile/likelihood.py ADDED
@@ -0,0 +1,596 @@
1
+ import numpy as np
2
+ from numpy.linalg import matrix_power
3
+ from numba import njit, prange
4
+ import scipy
5
+ from .migration_matrix import build_transition_matrices
6
+
7
+
8
+ @njit()
9
+ def _norm1(A):
10
+ """1-norm (max absolute column sum) — numba's np.linalg.norm
11
+ doesn't reliably support ord=1 for 2D arrays, so do it by hand."""
12
+ n = A.shape[1]
13
+ max_sum = 0.0
14
+ for j in range(n):
15
+ s = 0.0
16
+ for i in range(A.shape[0]):
17
+ s += abs(A[i, j])
18
+ if s > max_sum:
19
+ max_sum = s
20
+ return max_sum
21
+
22
+ @njit()
23
+ def expm(A):
24
+ n = A.shape[0]
25
+ I = np.eye(n, dtype=A.dtype)
26
+ # Pade(13) coefficients (Higham, 2005)
27
+ b0, b1, b2, b3, b4, b5, b6, b7 = (
28
+ 64764752532480000.0, 32382376266240000.0, 7771770303897600.0,
29
+ 1187353796428800.0, 129060195264000.0, 10559470521600.0,
30
+ 670442572800.0, 33522128640.0,
31
+ )
32
+ b8, b9, b10, b11, b12, b13 = (
33
+ 1323241920.0, 40840800.0, 960960.0, 16380.0, 182.0, 1.0,
34
+ )
35
+ theta13 = 5.371920351148152
36
+ normA = _norm1(A)
37
+ s = 0
38
+ if normA > theta13:
39
+ s = int(np.ceil(np.log2(normA / theta13)))
40
+ if s < 0:
41
+ s = 0
42
+ A = A / (2.0 ** s)
43
+ A2 = A @ A
44
+ A4 = A2 @ A2
45
+ A6 = A2 @ A4
46
+ U = A @ (A6 @ (b13 * A6 + b11 * A4 + b9 * A2)
47
+ + b7 * A6 + b5 * A4 + b3 * A2 + b1 * I)
48
+ V = (A6 @ (b12 * A6 + b10 * A4 + b8 * A2)
49
+ + b6 * A6 + b4 * A4 + b2 * A2 + b0 * I)
50
+ P = U + V
51
+ Q = V - U
52
+ R = np.ascontiguousarray(np.linalg.solve(Q, P))
53
+ for _ in range(s):
54
+ R = R @ R
55
+ return R
56
+
57
+ @njit()
58
+ def calc_current_pos(id, messages, parents, coal_rate):
59
+ """Calculates current node position as product of child messages
60
+
61
+ Parameters
62
+ ----------
63
+ id : int
64
+ ID of node
65
+ messages : np.array
66
+ Messages being passed in tree
67
+ parents : np.array
68
+ Parent IDs for each node
69
+
70
+ Returns
71
+ -------
72
+ current_pos : np.array
73
+ Probability distribution of node's current position given subtree below
74
+ """
75
+
76
+ children = np.where(parents==id)[0]
77
+ if len(children) == 0:
78
+ raise RuntimeError(f"Node {id} does not have any children. This should never happen.")
79
+ current_pos = messages[children[0]]
80
+ if len(children) > 1:
81
+ for i in range(1, len(children)):
82
+ current_pos = np.multiply(current_pos, messages[children[i]])
83
+ current_pos = np.multiply(coal_rate, current_pos)
84
+ return current_pos
85
+
86
+ @njit()
87
+ def calc_branch_message(
88
+ current_pos,
89
+ branch_above,
90
+ transition_matrices
91
+ ):
92
+ """Calculates the message to be passed along a branch above specified node
93
+
94
+ Parameters
95
+ ----------
96
+ id : int
97
+ ID of node
98
+ current_pos : np.array
99
+ Probability distribution of node's current position given subtree below
100
+ branch_above : np.array
101
+ Branch lengths above each node split across epochs. Shape is #epochs x #nodes.
102
+ direction : string
103
+
104
+
105
+ Returns
106
+ -------
107
+ current_pos : np.array
108
+ Probability distribution for location of lineage given subtree below. Length is #demes.
109
+ """
110
+
111
+ included_epochs = np.where(branch_above > 0)[0]
112
+ for epoch in included_epochs:
113
+ trans_prob = expm(transition_matrices[epoch]*branch_above[epoch])
114
+ current_pos = np.dot(trans_prob, current_pos)
115
+ return current_pos
116
+
117
+ @njit()
118
+ def calc_backward_messages(
119
+ parents,
120
+ branch_above,
121
+ node_epoch,
122
+ ids_asc_time,
123
+ sample_locations_array,
124
+ sample_ids,
125
+ backward_transition_matrices,
126
+ coal_rates
127
+ ):
128
+ """"""
129
+
130
+ num_demes = len(sample_locations_array[0])
131
+ messages = np.ones((len(parents), num_demes), dtype="float64")
132
+ loglikelihood = 0
133
+ for id in ids_asc_time:
134
+ if id in sample_ids:
135
+ current_pos = sample_locations_array[np.where(sample_ids==id)[0][0]]
136
+ else:
137
+ current_pos = calc_current_pos(
138
+ id,
139
+ messages,
140
+ parents,
141
+ coal_rates[node_epoch[id]]
142
+ )
143
+
144
+ # Also rescale so that underflow is not a problem and track scaler
145
+ s = np.sum(current_pos)
146
+ current_pos /= s
147
+ loglikelihood += np.log(s)
148
+
149
+ parent = parents[id]
150
+ if parent != -1:
151
+ messages[id] = calc_branch_message(
152
+ current_pos,
153
+ branch_above[id],
154
+ backward_transition_matrices
155
+ )
156
+ else: # collect roots here
157
+ messages[id] = current_pos
158
+
159
+ return loglikelihood, messages
160
+
161
+ @njit()
162
+ def calc_branch_message_pre(
163
+ current_pos,
164
+ branch_above,
165
+ exponentiated_transition_matrices
166
+ ):
167
+ """Calculates the message to be passed along a branch above specified node
168
+
169
+ Parameters
170
+ ----------
171
+ id : int
172
+ ID of node
173
+ current_pos : np.array
174
+ Probability distribution of node's current position given subtree below
175
+ branch_above : np.array
176
+ Branch lengths above each node split across epochs. Shape is #epochs x #nodes.
177
+ direction : string
178
+
179
+
180
+ Returns
181
+ -------
182
+ current_pos : np.array
183
+ Probability distribution for location of lineage given subtree below. Length is #demes.
184
+ """
185
+
186
+ included_epochs = np.where(branch_above > 0)[0]
187
+ for epoch in included_epochs:
188
+ trans_prob = matrix_power(exponentiated_transition_matrices[epoch], branch_above[epoch])
189
+ current_pos = np.dot(trans_prob, current_pos)
190
+ return current_pos
191
+
192
+ @njit()
193
+ def calc_backward_messages_pre(
194
+ parents,
195
+ branch_above,
196
+ node_epoch,
197
+ ids_asc_time,
198
+ sample_locations_array,
199
+ sample_ids,
200
+ exponentiated_backward_transition_matrices,
201
+ coal_rates
202
+ ):
203
+ """"""
204
+
205
+ num_demes = len(sample_locations_array[0])
206
+ messages = np.ones((len(parents), num_demes), dtype="float64")
207
+ loglikelihood = 0
208
+ for id in ids_asc_time:
209
+ if id in sample_ids:
210
+ current_pos = sample_locations_array[np.where(sample_ids==id)[0][0]]
211
+ else:
212
+ current_pos = calc_current_pos(
213
+ id,
214
+ messages,
215
+ parents,
216
+ coal_rates[node_epoch[id]]
217
+ )
218
+
219
+ # Also rescale so that underflow is not a problem and track scaler
220
+ s = np.sum(current_pos)
221
+ current_pos /= s
222
+ loglikelihood += np.log(s)
223
+
224
+ parent = parents[id]
225
+ if parent != -1:
226
+ messages[id] = calc_branch_message_pre(
227
+ current_pos,
228
+ branch_above[id],
229
+ exponentiated_backward_transition_matrices
230
+ )
231
+ else: # collect roots here
232
+ messages[id] = current_pos
233
+
234
+ return loglikelihood, messages
235
+
236
+
237
+ @njit()
238
+ def _calc_branch_message_pre_log(
239
+ current_pos,
240
+ branch_above,
241
+ exponentiated_transition_matrices,
242
+ epoch_durations,
243
+ precalculated_transitions_log,
244
+ ):
245
+ """Calculates the message to be passed along a branch above specified node
246
+
247
+ Parameters
248
+ ----------
249
+ id : int
250
+ ID of node
251
+ current_pos : np.array
252
+ Probability distribution of node's current position given subtree below
253
+ unique_branch_lengths : list
254
+ Arrays containing unique branch lengths in each epoch
255
+ precalculated_transitions : list
256
+ Arrays of transition probabilities corresponding with the unique_branch_lengths, converted to log space
257
+
258
+ Returns
259
+ -------
260
+ message : np.array
261
+ Probability distribution for location of lineage given subtree below. Length is #demes.
262
+ """
263
+
264
+ included_epochs = np.where(branch_above > 0)[0]
265
+ for epoch in included_epochs:
266
+ if branch_above[epoch] == epoch_durations[epoch]:
267
+ trans_prob_log = precalculated_transitions_log[epoch]
268
+ else:
269
+ trans_prob_log = np.log(np.maximum(matrix_power(exponentiated_transition_matrices[epoch], branch_above[epoch]), 1e-99))
270
+ current_pos = logsumexp_custom(trans_prob_log + current_pos, axis=1)[np.newaxis, :]
271
+ return current_pos
272
+
273
+ @njit()
274
+ def likelihood_of_tree_pre_log(
275
+ parents,
276
+ branch_above,
277
+ node_epoch,
278
+ ids_asc_time,
279
+ sample_locations_array_log,
280
+ sample_ids,
281
+ exponentiated_backward_transition_matrices,
282
+ epoch_durations,
283
+ precalculated_transitions_log,
284
+ coal_rates_log
285
+ ):
286
+ """Computes log-likelihood of tree in log-space to avoid underflow
287
+
288
+ Parameters
289
+ ----------
290
+ parents : np.array
291
+ Parent IDs for each node. Length is #nodes.
292
+ branch_above : np.array
293
+ Branch lengths above each node split across epochs. Shape is #epochs x #nodes.
294
+ node_epoch : np.array
295
+ Epochs of each node. Length is #nodes.
296
+ ids_asc_time : np.array
297
+ IDs of nodes in tree in time ascending order. Length is #nodes.
298
+ sample_locations_array_log : np.array
299
+ Vector representation of sample locations. Shape is #samples x #demes.
300
+ sample_ids : np.array
301
+ IDs of sample nodes in tree. Length is #samples.
302
+ backward_transition_matrices : np.array
303
+
304
+ coal_rates_log : numpy.ndarray
305
+ Recipricol of suitability values for each deme in each epoch, converted to log space. Shape is #epochs x #demes.
306
+
307
+ Returns
308
+ -------
309
+ loglikelihood : float
310
+ Log-likelihood of the tree
311
+ """
312
+
313
+ num_demes = len(sample_locations_array_log[0])
314
+ messages = np.zeros((len(parents), num_demes), dtype="float64")
315
+ loglikelihood = 0
316
+ for id in ids_asc_time:
317
+ if id in sample_ids:
318
+ current_pos = sample_locations_array_log[np.where(sample_ids==id)[0][0]][np.newaxis, :]
319
+ else:
320
+ current_pos = _calc_current_pos_log(
321
+ id,
322
+ messages,
323
+ parents,
324
+ coal_rates_log[node_epoch[id]]
325
+ )
326
+ messages[id] = _calc_branch_message_pre_log(
327
+ current_pos,
328
+ branch_above[id],
329
+ exponentiated_backward_transition_matrices,
330
+ epoch_durations,
331
+ precalculated_transitions_log
332
+ )
333
+ if parents[id] == -1:
334
+ loglikelihood += logsumexp_custom(current_pos, axis=1)[0]
335
+ return loglikelihood
336
+
337
+ @njit()
338
+ def _calc_current_pos_log(id, messages, parents, coal_rates):
339
+ """Calculates current node position as product of child messages
340
+
341
+ Parameters
342
+ ----------
343
+ id : int
344
+ ID of node
345
+ messages : np.array
346
+ Messages being passed in tree. Shape is #nodes x #demes.
347
+ parents : np.array
348
+ Parent IDs for each node. Length is #nodes.
349
+ coal_rates : numpy.ndarray
350
+ Recipricol of suitability values for each deme in the node's corresponding epoch, converted to log space
351
+
352
+ Returns
353
+ -------
354
+ current_pos : np.array
355
+ Probability distribution of node's current position given subtree below. Length is #demes.
356
+ """
357
+
358
+ current_pos = coal_rates + np.sum(messages[np.where(parents==id)[0]], axis=0)[np.newaxis, :]
359
+ return current_pos
360
+
361
+ @njit()
362
+ def logsumexp_custom(x, axis):
363
+ """LogSumExp function that can be accessed from numba
364
+
365
+ Parameters
366
+ ----------
367
+ x : numpy.ndarray
368
+ Vector to transform
369
+ axis : int
370
+ Which axis of the numpy.array to transform over
371
+ """
372
+
373
+ c = np.max(x)
374
+ return c + np.log(np.sum(np.exp(x - c), axis=axis))
375
+
376
+ @njit()
377
+ def _calc_branch_message_log(
378
+ current_pos,
379
+ branch_above,
380
+ transition_matrices
381
+ ):
382
+ """Calculates the message to be passed along a branch above specified node
383
+
384
+ Parameters
385
+ ----------
386
+ id : int
387
+ ID of node
388
+ current_pos : np.array
389
+ Probability distribution of node's current position given subtree below
390
+ unique_branch_lengths : list
391
+ Arrays containing unique branch lengths in each epoch
392
+ precalculated_transitions : list
393
+ Arrays of transition probabilities corresponding with the unique_branch_lengths, converted to log space
394
+
395
+ Returns
396
+ -------
397
+ message : np.array
398
+ Probability distribution for location of lineage given subtree below. Length is #demes.
399
+ """
400
+
401
+ included_epochs = np.where(branch_above > 0)[0]
402
+ for epoch in included_epochs:
403
+ trans_prob_log = np.log(np.maximum(expm(transition_matrices[epoch]*branch_above[epoch]), 1e-99))
404
+ current_pos = logsumexp_custom(trans_prob_log + current_pos, axis=1)[np.newaxis, :]
405
+ return current_pos
406
+
407
+ @njit()
408
+ def likelihood_of_tree_log(
409
+ parents,
410
+ branch_above,
411
+ node_epoch,
412
+ ids_asc_time,
413
+ sample_locations_array_log,
414
+ sample_ids,
415
+ backward_transition_matrices,
416
+ coal_rates_log
417
+ ):
418
+ """Computes log-likelihood of tree in log-space to avoid underflow
419
+
420
+ Parameters
421
+ ----------
422
+ parents : np.array
423
+ Parent IDs for each node. Length is #nodes.
424
+ branch_above : np.array
425
+ Branch lengths above each node split across epochs. Shape is #epochs x #nodes.
426
+ node_epoch : np.array
427
+ Epochs of each node. Length is #nodes.
428
+ ids_asc_time : np.array
429
+ IDs of nodes in tree in time ascending order. Length is #nodes.
430
+ sample_locations_array_log : np.array
431
+ Vector representation of sample locations. Shape is #samples x #demes.
432
+ sample_ids : np.array
433
+ IDs of sample nodes in tree. Length is #samples.
434
+ backward_transition_matrices : np.array
435
+
436
+ coal_rates_log : numpy.ndarray
437
+ Recipricol of suitability values for each deme in each epoch, converted to log space. Shape is #epochs x #demes.
438
+
439
+ Returns
440
+ -------
441
+ loglikelihood : float
442
+ Log-likelihood of the tree
443
+ """
444
+
445
+ num_demes = len(sample_locations_array_log[0])
446
+ messages = np.zeros((len(parents), num_demes), dtype="float64")
447
+ loglikelihood = 0
448
+ for id in ids_asc_time:
449
+ if id in sample_ids:
450
+ current_pos = sample_locations_array_log[np.where(sample_ids==id)[0][0]][np.newaxis, :]
451
+ else:
452
+ current_pos = _calc_current_pos_log(
453
+ id,
454
+ messages,
455
+ parents,
456
+ coal_rates_log[node_epoch[id]]
457
+ )
458
+ messages[id] = _calc_branch_message_log(
459
+ current_pos,
460
+ branch_above[id],
461
+ backward_transition_matrices
462
+ )
463
+ if parents[id] == -1:
464
+ loglikelihood += logsumexp_custom(current_pos, axis=1)[0]
465
+ return loglikelihood
466
+
467
+ @njit(parallel=True)
468
+ def _process_trees(
469
+ parents,
470
+ branch_above,
471
+ node_epoch,
472
+ ids_asc_time,
473
+ sample_locations_array_log,
474
+ sample_ids,
475
+ exponentiated_backward_transition_matrices,
476
+ epoch_durations,
477
+ precalculated_transitions_log,
478
+ coal_rates_log
479
+ ):
480
+ """
481
+ Parameters
482
+ ----------
483
+ parents : list
484
+ Arrays containing ID of parent for each node, one array per tree
485
+ branch_above : list
486
+ Arrays containing branch above length (split across epochs) for each node, one array per tree
487
+ node_epoch : list
488
+ Arrays containing the epochs of each node, one array per tree
489
+ ids_asc_time : List
490
+ Arrays of nodes IDs in time ascending order, one array per tree
491
+ sample_locations_array : numpy.ndarray
492
+ Probability distribution vector for each sample location, converted to log space
493
+ sample_ids : numpy.ndarray
494
+ Order of sample IDs for `sample_locations_array`
495
+ backward_transition_matrices : np.array
496
+
497
+ coal_rates_log : numpy.ndarray
498
+ Recipricol of suitability values for each deme in each epoch, converted to log space
499
+
500
+ Returns
501
+ -------
502
+ composite_likelihood : float
503
+ Composite log-likelihood across the trees
504
+ """
505
+
506
+ composite_likelihood = 0
507
+ for i in prange(len(branch_above)):
508
+ like = likelihood_of_tree_pre_log(
509
+ parents=parents[i],
510
+ branch_above=branch_above[i],
511
+ node_epoch=node_epoch[i],
512
+ ids_asc_time=ids_asc_time[i],
513
+ sample_locations_array_log=sample_locations_array_log,
514
+ sample_ids=sample_ids,
515
+ exponentiated_backward_transition_matrices=exponentiated_backward_transition_matrices,
516
+ epoch_durations=epoch_durations,
517
+ precalculated_transitions_log=precalculated_transitions_log,
518
+ coal_rates_log=coal_rates_log
519
+ )
520
+ composite_likelihood += like
521
+ return composite_likelihood
522
+
523
+ def calc_composite_likelihood_for_parameters(
524
+ parameters,
525
+ world_map,
526
+ parents,
527
+ branch_above,
528
+ node_epoch,
529
+ ids_asc_time,
530
+ sample_locations_array_log,
531
+ sample_ids,
532
+ output_file=None,
533
+ verbose=False
534
+ ):
535
+ """
536
+ Parameters
537
+ ----------
538
+ parameters : numpy.ndarray
539
+ Combination of parameters used to build the migration surface
540
+ world_map : argile.WorldMap
541
+ Custom object built using the `demes.tsv`, `connections.tsv`, and `samples.tsv` files
542
+ parents : list
543
+ Arrays containing ID of parent for each node, one array per tree
544
+ branch_above : list
545
+ Arrays containing branch above length (split across epochs) for each node, one array per tree
546
+ node_epoch : list
547
+ Arrays containing the epochs of each node, one array per tree
548
+ ids_asc_time : list
549
+ Arrays of nodes IDs in time ascending order, one array per tree
550
+ sample_locations_array_log : numpy.ndarray
551
+ Probability distribution vector for each sample location (generally 0 in all demes except one)
552
+ sample_ids : numpy.ndarray
553
+ Order of sample IDs for `sample_locations_array`
554
+ output_file : str
555
+ Path to an output file to write to (default is `None`, ignored)
556
+ verbose : bool
557
+ Whether to print log-likelihoods to the terminal (default is False)
558
+
559
+ Returns
560
+ -------
561
+ composite_likelihood : float
562
+ Log-likelihood of the parameter combination
563
+ """
564
+
565
+ backward_transition_matrices = build_transition_matrices(world_map=world_map, parameters=parameters, direction="backward")
566
+
567
+ exponentiated_backward_transition_matrices = np.zeros(backward_transition_matrices.shape)
568
+ precalculated_full_epoch_branch_lengths = np.zeros(backward_transition_matrices.shape)
569
+ for i in range(len(backward_transition_matrices)):
570
+ exponentiated_backward_transition_matrices[i] = scipy.linalg.expm(backward_transition_matrices[i])
571
+ if world_map.epoch_durations[i] != -1:
572
+ precalculated_full_epoch_branch_lengths[i] = matrix_power(exponentiated_backward_transition_matrices[i], world_map.epoch_durations[i])
573
+ precalculated_full_epoch_branch_lengths_log = np.log(np.maximum(precalculated_full_epoch_branch_lengths, 1e-99))
574
+
575
+ pop_sizes = np.maximum(world_map.suitabilities, 1e-99)
576
+ coal_rates_log = np.log(1 / (np.maximum(pop_sizes, 0.1)))
577
+
578
+ composite_likelihood = _process_trees(
579
+ parents=parents,
580
+ branch_above=branch_above,
581
+ node_epoch=node_epoch,
582
+ ids_asc_time=ids_asc_time,
583
+ sample_locations_array_log=sample_locations_array_log,
584
+ sample_ids=sample_ids,
585
+ exponentiated_backward_transition_matrices=exponentiated_backward_transition_matrices,
586
+ epoch_durations=world_map.epoch_durations,
587
+ precalculated_transitions_log=precalculated_full_epoch_branch_lengths_log,
588
+ coal_rates_log=coal_rates_log
589
+ )
590
+
591
+ if output_file is not None:
592
+ with open(output_file, "a") as outfile:
593
+ outfile.write(f"{parameters}\t{composite_likelihood}\n")
594
+ if verbose:
595
+ print(parameters, composite_likelihood, flush=True)
596
+ return composite_likelihood