monopoly-polysome 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.
- monopoly/__init__.py +24 -0
- monopoly/_design.py +364 -0
- monopoly/_normalization.py +132 -0
- monopoly/_provenance.py +87 -0
- monopoly/_validation.py +38 -0
- monopoly/dataset.py +412 -0
- monopoly/exceptions.py +19 -0
- monopoly/model.py +605 -0
- monopoly/py.typed +1 -0
- monopoly/results.py +136 -0
- monopoly_polysome-0.1.0.dist-info/METADATA +188 -0
- monopoly_polysome-0.1.0.dist-info/RECORD +14 -0
- monopoly_polysome-0.1.0.dist-info/WHEEL +4 -0
- monopoly_polysome-0.1.0.dist-info/licenses/LICENSE +21 -0
monopoly/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from monopoly.dataset import MonopolyDataset
|
|
2
|
+
from monopoly.exceptions import (
|
|
3
|
+
ContrastError,
|
|
4
|
+
DataValidationError,
|
|
5
|
+
DesignError,
|
|
6
|
+
ModelFitError,
|
|
7
|
+
MonopolyError,
|
|
8
|
+
)
|
|
9
|
+
from monopoly.model import MonopolyModel
|
|
10
|
+
from monopoly.results import MonopolyResults
|
|
11
|
+
|
|
12
|
+
__version__ = '0.1.0'
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
'ContrastError',
|
|
16
|
+
'DataValidationError',
|
|
17
|
+
'DesignError',
|
|
18
|
+
'ModelFitError',
|
|
19
|
+
'MonopolyDataset',
|
|
20
|
+
'MonopolyError',
|
|
21
|
+
'MonopolyModel',
|
|
22
|
+
'MonopolyResults',
|
|
23
|
+
'__version__',
|
|
24
|
+
]
|
monopoly/_design.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import keyword
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from formulaic import model_matrix
|
|
11
|
+
from pandas.api.types import (
|
|
12
|
+
is_bool_dtype,
|
|
13
|
+
is_complex_dtype,
|
|
14
|
+
is_numeric_dtype,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from monopoly.dataset import _FRACTIONS, MonopolyDataset
|
|
18
|
+
from monopoly.exceptions import ContrastError, DesignError
|
|
19
|
+
|
|
20
|
+
_FRACTION_ALIASES = {
|
|
21
|
+
'input': 'input',
|
|
22
|
+
'mono': 'monosome',
|
|
23
|
+
'monosome': 'monosome',
|
|
24
|
+
'poly': 'polysome',
|
|
25
|
+
'polysome': 'polysome',
|
|
26
|
+
}
|
|
27
|
+
_RESERVED_TERMS = frozenset({'condition', 'fraction', 'participantId'})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen = True)
|
|
31
|
+
class DesignMatrices:
|
|
32
|
+
full: pd.DataFrame
|
|
33
|
+
reduced: pd.DataFrame
|
|
34
|
+
reference: str
|
|
35
|
+
test: str
|
|
36
|
+
covariates: tuple[str, ...]
|
|
37
|
+
full_formula: str
|
|
38
|
+
reduced_formula: str
|
|
39
|
+
_contrasts: Mapping[tuple[str, str], np.ndarray]
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def residual_degrees_of_freedom(self) -> int:
|
|
43
|
+
return self.full.shape[0] - self.full.shape[1]
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def omnibus_degrees_of_freedom(self) -> int:
|
|
47
|
+
return self.full.shape[1] - self.reduced.shape[1]
|
|
48
|
+
|
|
49
|
+
def contrast(self, numerator: str, denominator: str) -> np.ndarray:
|
|
50
|
+
numerator = _canonical_fraction(numerator)
|
|
51
|
+
denominator = _canonical_fraction(denominator)
|
|
52
|
+
if numerator == denominator:
|
|
53
|
+
raise ContrastError('Contrast fractions must be different.')
|
|
54
|
+
|
|
55
|
+
return self._contrasts[(numerator, denominator)].copy()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_design(
|
|
59
|
+
dataset: MonopolyDataset,
|
|
60
|
+
*,
|
|
61
|
+
reference: str,
|
|
62
|
+
covariates: str | Sequence[str] | None = None,
|
|
63
|
+
) -> DesignMatrices:
|
|
64
|
+
if not isinstance(dataset, MonopolyDataset):
|
|
65
|
+
raise DesignError('dataset must be a MonopolyDataset.')
|
|
66
|
+
|
|
67
|
+
_, metadata = dataset._to_long()
|
|
68
|
+
reference, test = _resolve_conditions(metadata, reference = reference)
|
|
69
|
+
covariates = _validate_covariates(metadata, covariates = covariates)
|
|
70
|
+
metadata = _prepare_metadata(
|
|
71
|
+
metadata,
|
|
72
|
+
participants = dataset.participants,
|
|
73
|
+
reference = reference,
|
|
74
|
+
test = test,
|
|
75
|
+
covariates = covariates,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
reduced_formula = _make_formula([
|
|
79
|
+
'participantId',
|
|
80
|
+
'fraction',
|
|
81
|
+
*covariates,
|
|
82
|
+
])
|
|
83
|
+
conceptual_full_formula = _make_formula([
|
|
84
|
+
'participantId',
|
|
85
|
+
'fraction',
|
|
86
|
+
*covariates,
|
|
87
|
+
'condition:fraction',
|
|
88
|
+
])
|
|
89
|
+
expanded_full_formula = _make_formula([
|
|
90
|
+
'participantId',
|
|
91
|
+
'fraction',
|
|
92
|
+
*covariates,
|
|
93
|
+
'condition',
|
|
94
|
+
'fraction:condition',
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
reduced = _materialize(reduced_formula, metadata)
|
|
98
|
+
expanded = _materialize(expanded_full_formula, metadata)
|
|
99
|
+
condition_columns = _term_columns(expanded, term_name = 'condition')
|
|
100
|
+
if len(condition_columns) != 1: # pragma: no cover
|
|
101
|
+
raise DesignError('The condition main effect could not be identified uniquely.')
|
|
102
|
+
|
|
103
|
+
# Participant effects already encode each participant's condition. Removing the
|
|
104
|
+
# condition main effect leaves only estimable condition-by-fraction differences.
|
|
105
|
+
full = expanded.drop(columns = condition_columns)
|
|
106
|
+
_validate_design_matrix(reduced, name = 'Reduced')
|
|
107
|
+
_validate_design_matrix(full, name = 'Full')
|
|
108
|
+
|
|
109
|
+
omnibus_df = full.shape[1] - reduced.shape[1]
|
|
110
|
+
if omnibus_df != len(_FRACTIONS) - 1: # pragma: no cover
|
|
111
|
+
raise DesignError(
|
|
112
|
+
'The condition-by-fraction interaction is not fully estimable.',
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
contrasts = _build_contrasts(
|
|
116
|
+
expanded,
|
|
117
|
+
metadata,
|
|
118
|
+
condition_columns = condition_columns,
|
|
119
|
+
reference = reference,
|
|
120
|
+
test = test,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return DesignMatrices(
|
|
124
|
+
full = full,
|
|
125
|
+
reduced = reduced,
|
|
126
|
+
reference = reference,
|
|
127
|
+
test = test,
|
|
128
|
+
covariates = covariates,
|
|
129
|
+
full_formula = conceptual_full_formula,
|
|
130
|
+
reduced_formula = reduced_formula,
|
|
131
|
+
_contrasts = MappingProxyType(contrasts),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_conditions(metadata: pd.DataFrame, *, reference: str) -> tuple[str, str]:
|
|
136
|
+
if not isinstance(reference, str) or not reference.strip():
|
|
137
|
+
raise DesignError('reference must be a nonempty string.')
|
|
138
|
+
|
|
139
|
+
conditions = metadata['condition'].drop_duplicates().tolist()
|
|
140
|
+
if reference not in conditions:
|
|
141
|
+
expected = ', '.join(conditions)
|
|
142
|
+
raise DesignError(
|
|
143
|
+
f'Unknown reference {reference!r}. Expected one of: {expected}.',
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
test = next(condition for condition in conditions if condition != reference)
|
|
147
|
+
return reference, test
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _validate_covariates(
|
|
151
|
+
metadata: pd.DataFrame,
|
|
152
|
+
*,
|
|
153
|
+
covariates: str | Sequence[str] | None,
|
|
154
|
+
) -> tuple[str, ...]:
|
|
155
|
+
covariates = _normalize_covariates(covariates)
|
|
156
|
+
for name in covariates:
|
|
157
|
+
_validate_covariate(metadata, name = name)
|
|
158
|
+
|
|
159
|
+
return covariates
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _normalize_covariates(
|
|
163
|
+
covariates: str | Sequence[str] | None,
|
|
164
|
+
) -> tuple[str, ...]:
|
|
165
|
+
if covariates is None:
|
|
166
|
+
return ()
|
|
167
|
+
if isinstance(covariates, str):
|
|
168
|
+
covariates = (covariates,)
|
|
169
|
+
else:
|
|
170
|
+
try:
|
|
171
|
+
covariates = tuple(covariates)
|
|
172
|
+
except TypeError as error:
|
|
173
|
+
raise DesignError(
|
|
174
|
+
'covariates must be a string or sequence of strings.',
|
|
175
|
+
) from error
|
|
176
|
+
|
|
177
|
+
if any(not isinstance(name, str) or not name.strip() for name in covariates):
|
|
178
|
+
raise DesignError('covariates must contain nonempty strings.')
|
|
179
|
+
if len(covariates) != len(set(covariates)):
|
|
180
|
+
raise DesignError('covariates cannot contain duplicate names.')
|
|
181
|
+
|
|
182
|
+
return tuple(covariates)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _validate_covariate(metadata: pd.DataFrame, *, name: str) -> None:
|
|
186
|
+
if name in _RESERVED_TERMS:
|
|
187
|
+
raise DesignError(f'{name!r} is a reserved design term.')
|
|
188
|
+
if not name.isidentifier() or keyword.iskeyword(name):
|
|
189
|
+
raise DesignError(
|
|
190
|
+
f'Covariate {name!r} must be a valid Python identifier.',
|
|
191
|
+
)
|
|
192
|
+
if name not in metadata.columns:
|
|
193
|
+
raise DesignError(f'Covariate {name!r} is not present in metadata.')
|
|
194
|
+
|
|
195
|
+
values = metadata[name]
|
|
196
|
+
if values.isna().any():
|
|
197
|
+
raise DesignError(f'Covariate {name!r} cannot contain missing values.')
|
|
198
|
+
_validate_covariate_values(values, name = name)
|
|
199
|
+
if _is_participant_level(values, metadata['participantId']):
|
|
200
|
+
raise DesignError(
|
|
201
|
+
f'Covariate {name!r} does not vary within participants and is '
|
|
202
|
+
'already absorbed by the participant effect.',
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _validate_covariate_values(values: pd.Series, *, name: str) -> None:
|
|
207
|
+
if is_complex_dtype(values.dtype):
|
|
208
|
+
raise DesignError(f'Covariate {name!r} cannot contain complex values.')
|
|
209
|
+
if is_numeric_dtype(values.dtype) and not is_bool_dtype(values.dtype):
|
|
210
|
+
numeric = values.to_numpy(dtype = np.float64)
|
|
211
|
+
if not np.isfinite(numeric).all():
|
|
212
|
+
raise DesignError(f'Covariate {name!r} must contain finite values.')
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
if is_bool_dtype(values.dtype):
|
|
216
|
+
return
|
|
217
|
+
if all(
|
|
218
|
+
isinstance(value, str) and value.strip()
|
|
219
|
+
for value in values
|
|
220
|
+
):
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
raise DesignError(
|
|
224
|
+
f'Covariate {name!r} must contain numeric, boolean, or string values.',
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _is_participant_level(values: pd.Series, participants: pd.Series) -> bool:
|
|
229
|
+
frame = pd.DataFrame({
|
|
230
|
+
'participantId': participants.to_numpy(),
|
|
231
|
+
'value': values.to_numpy(),
|
|
232
|
+
})
|
|
233
|
+
unique_within_participant = frame.groupby(
|
|
234
|
+
'participantId',
|
|
235
|
+
observed = True,
|
|
236
|
+
)['value'].nunique(dropna = False)
|
|
237
|
+
return bool((unique_within_participant == 1).all())
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _prepare_metadata(
|
|
241
|
+
metadata: pd.DataFrame,
|
|
242
|
+
*,
|
|
243
|
+
participants: pd.Index,
|
|
244
|
+
reference: str,
|
|
245
|
+
test: str,
|
|
246
|
+
covariates: tuple[str, ...],
|
|
247
|
+
) -> pd.DataFrame:
|
|
248
|
+
prepared = metadata.loc[
|
|
249
|
+
:,
|
|
250
|
+
['participantId', 'fraction', 'condition', *covariates],
|
|
251
|
+
].copy(deep = True)
|
|
252
|
+
prepared['participantId'] = pd.Categorical(
|
|
253
|
+
prepared['participantId'],
|
|
254
|
+
categories = participants,
|
|
255
|
+
)
|
|
256
|
+
prepared['fraction'] = pd.Categorical(
|
|
257
|
+
prepared['fraction'],
|
|
258
|
+
categories = _FRACTIONS,
|
|
259
|
+
)
|
|
260
|
+
prepared['condition'] = pd.Categorical(
|
|
261
|
+
prepared['condition'],
|
|
262
|
+
categories = [reference, test],
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
for name in covariates:
|
|
266
|
+
values = prepared[name]
|
|
267
|
+
if is_bool_dtype(values.dtype) or not is_numeric_dtype(values.dtype):
|
|
268
|
+
prepared[name] = pd.Categorical(values)
|
|
269
|
+
|
|
270
|
+
return prepared
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _make_formula(terms: Sequence[str]) -> str:
|
|
274
|
+
return '~ ' + ' + '.join(terms)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _materialize(formula: str, metadata: pd.DataFrame) -> pd.DataFrame:
|
|
278
|
+
try:
|
|
279
|
+
matrix = model_matrix(formula, metadata, output = 'pandas')
|
|
280
|
+
except Exception as error:
|
|
281
|
+
raise DesignError(f'Could not construct design matrix: {error}') from error
|
|
282
|
+
|
|
283
|
+
if matrix.shape[0] != metadata.shape[0]: # pragma: no cover
|
|
284
|
+
raise DesignError('Design construction unexpectedly removed libraries.')
|
|
285
|
+
return matrix
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _term_columns(matrix: pd.DataFrame, *, term_name: str) -> list[str]:
|
|
289
|
+
term_indices = matrix.model_spec.term_indices
|
|
290
|
+
positions = [
|
|
291
|
+
position
|
|
292
|
+
for term, indices in term_indices.items()
|
|
293
|
+
if str(term) == term_name
|
|
294
|
+
for position in indices
|
|
295
|
+
]
|
|
296
|
+
return [matrix.columns[position] for position in positions]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _validate_design_matrix(matrix: pd.DataFrame, *, name: str) -> None:
|
|
300
|
+
values = matrix.to_numpy(dtype = np.float64)
|
|
301
|
+
if not np.isfinite(values).all():
|
|
302
|
+
raise DesignError(f'{name} design matrix contains non-finite values.')
|
|
303
|
+
|
|
304
|
+
rank = np.linalg.matrix_rank(values)
|
|
305
|
+
if rank < matrix.shape[1]:
|
|
306
|
+
raise DesignError(
|
|
307
|
+
f'{name} design matrix is rank deficient. Selected covariates are '
|
|
308
|
+
'confounded with participant, fraction, or condition.',
|
|
309
|
+
)
|
|
310
|
+
if matrix.shape[0] - rank <= 0:
|
|
311
|
+
raise DesignError(f'{name} design matrix has no residual degrees of freedom.')
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _build_contrasts(
|
|
315
|
+
expanded: pd.DataFrame,
|
|
316
|
+
metadata: pd.DataFrame,
|
|
317
|
+
*,
|
|
318
|
+
condition_columns: list[str],
|
|
319
|
+
reference: str,
|
|
320
|
+
test: str,
|
|
321
|
+
) -> dict[tuple[str, str], np.ndarray]:
|
|
322
|
+
contrasts = {}
|
|
323
|
+
for numerator in _FRACTIONS:
|
|
324
|
+
for denominator in _FRACTIONS:
|
|
325
|
+
if numerator == denominator:
|
|
326
|
+
continue
|
|
327
|
+
contrasts[(numerator, denominator)] = _contrast_vector(
|
|
328
|
+
expanded,
|
|
329
|
+
metadata,
|
|
330
|
+
condition_columns = condition_columns,
|
|
331
|
+
numerator = numerator,
|
|
332
|
+
denominator = denominator,
|
|
333
|
+
reference = reference,
|
|
334
|
+
test = test,
|
|
335
|
+
)
|
|
336
|
+
return contrasts
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _contrast_vector(
|
|
340
|
+
expanded: pd.DataFrame,
|
|
341
|
+
metadata: pd.DataFrame,
|
|
342
|
+
*,
|
|
343
|
+
condition_columns: list[str],
|
|
344
|
+
numerator: str,
|
|
345
|
+
denominator: str,
|
|
346
|
+
reference: str,
|
|
347
|
+
test: str,
|
|
348
|
+
) -> np.ndarray:
|
|
349
|
+
rows = pd.concat([metadata.iloc[[0]]] * 4, ignore_index = True)
|
|
350
|
+
rows.loc[:, 'condition'] = [test, test, reference, reference]
|
|
351
|
+
rows.loc[:, 'fraction'] = [numerator, denominator, numerator, denominator]
|
|
352
|
+
|
|
353
|
+
encoded = expanded.model_spec.get_model_matrix(rows)
|
|
354
|
+
encoded = encoded.drop(columns = condition_columns).to_numpy(dtype = np.float64)
|
|
355
|
+
return encoded[0] - encoded[1] - encoded[2] + encoded[3]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _canonical_fraction(fraction: str) -> str:
|
|
359
|
+
if not isinstance(fraction, str) or fraction not in _FRACTION_ALIASES:
|
|
360
|
+
expected = ', '.join(_FRACTION_ALIASES)
|
|
361
|
+
raise ContrastError(
|
|
362
|
+
f'Unknown fraction {fraction!r}. Expected one of: {expected}.',
|
|
363
|
+
)
|
|
364
|
+
return _FRACTION_ALIASES[fraction]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Collection, Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from monopoly._validation import validate_control_genes
|
|
11
|
+
from monopoly.dataset import _FRACTIONS, MonopolyDataset
|
|
12
|
+
from monopoly.exceptions import ModelFitError
|
|
13
|
+
|
|
14
|
+
_FIT_TYPES = frozenset({'iterative', 'poscounts', 'ratio'})
|
|
15
|
+
_SCOPES = frozenset({'fraction', 'joint'})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen = True)
|
|
19
|
+
class NormalizationResult:
|
|
20
|
+
size_factors: pd.Series
|
|
21
|
+
scope: str
|
|
22
|
+
fit_type: str
|
|
23
|
+
effective_fit_types: Mapping[str, str]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def estimate_size_factors(
|
|
27
|
+
dataset: MonopolyDataset,
|
|
28
|
+
*,
|
|
29
|
+
scope: str = 'fraction',
|
|
30
|
+
fit_type: str = 'ratio',
|
|
31
|
+
control_genes: Collection[str] | None = None,
|
|
32
|
+
n_cpus: int | None = 1,
|
|
33
|
+
) -> NormalizationResult:
|
|
34
|
+
"""Estimate joint or fraction-wise DESeq2 size factors."""
|
|
35
|
+
_validate_options(scope = scope, fit_type = fit_type, n_cpus = n_cpus)
|
|
36
|
+
controls = validate_control_genes(
|
|
37
|
+
control_genes,
|
|
38
|
+
genes = dataset.genes,
|
|
39
|
+
)
|
|
40
|
+
counts, metadata = dataset._to_long()
|
|
41
|
+
|
|
42
|
+
if scope == 'joint':
|
|
43
|
+
size_factors, effective_fit_type = _estimate_group(
|
|
44
|
+
counts,
|
|
45
|
+
fit_type = fit_type,
|
|
46
|
+
control_genes = controls,
|
|
47
|
+
n_cpus = n_cpus,
|
|
48
|
+
)
|
|
49
|
+
effective_fit_types = {'joint': effective_fit_type}
|
|
50
|
+
else:
|
|
51
|
+
size_factors = pd.Series(index = counts.index, dtype = np.float64)
|
|
52
|
+
effective_fit_types = {}
|
|
53
|
+
for fraction in _FRACTIONS:
|
|
54
|
+
selected = metadata['fraction'].eq(fraction)
|
|
55
|
+
estimates, effective_fit_type = _estimate_group(
|
|
56
|
+
counts.loc[selected],
|
|
57
|
+
fit_type = fit_type,
|
|
58
|
+
control_genes = controls,
|
|
59
|
+
n_cpus = n_cpus,
|
|
60
|
+
)
|
|
61
|
+
size_factors.loc[selected] = estimates.to_numpy()
|
|
62
|
+
effective_fit_types[fraction] = effective_fit_type
|
|
63
|
+
|
|
64
|
+
if not np.isfinite(size_factors).all() or (size_factors <= 0).any():
|
|
65
|
+
raise ModelFitError('Normalization produced invalid size factors.')
|
|
66
|
+
size_factors.name = 'sizeFactor'
|
|
67
|
+
return NormalizationResult(
|
|
68
|
+
size_factors = size_factors,
|
|
69
|
+
scope = scope,
|
|
70
|
+
fit_type = fit_type,
|
|
71
|
+
effective_fit_types = MappingProxyType(effective_fit_types),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _estimate_group(
|
|
76
|
+
counts: pd.DataFrame,
|
|
77
|
+
*,
|
|
78
|
+
fit_type: str,
|
|
79
|
+
control_genes: list[str] | None,
|
|
80
|
+
n_cpus: int | None,
|
|
81
|
+
) -> tuple[pd.Series, str]:
|
|
82
|
+
from pydeseq2.dds import DeseqDataSet
|
|
83
|
+
|
|
84
|
+
sample_ids = pd.Index(
|
|
85
|
+
[f'library{position}' for position in range(len(counts))],
|
|
86
|
+
name = 'libraryId',
|
|
87
|
+
)
|
|
88
|
+
backend_counts = counts.copy(deep = True)
|
|
89
|
+
backend_counts.index = sample_ids
|
|
90
|
+
metadata = pd.DataFrame(index = sample_ids)
|
|
91
|
+
design = pd.DataFrame({'Intercept': np.ones(len(counts))}, index = sample_ids)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
dds = DeseqDataSet(
|
|
95
|
+
counts = backend_counts,
|
|
96
|
+
metadata = metadata,
|
|
97
|
+
design = design,
|
|
98
|
+
size_factors_fit_type = fit_type,
|
|
99
|
+
control_genes = control_genes,
|
|
100
|
+
refit_cooks = False,
|
|
101
|
+
n_cpus = n_cpus,
|
|
102
|
+
quiet = True,
|
|
103
|
+
)
|
|
104
|
+
dds.fit_size_factors()
|
|
105
|
+
except Exception as error:
|
|
106
|
+
raise ModelFitError(f'Could not estimate size factors: {error}') from error
|
|
107
|
+
|
|
108
|
+
effective_fit_type = fit_type
|
|
109
|
+
if fit_type == 'ratio' and dds.logmeans is None:
|
|
110
|
+
effective_fit_type = 'iterative'
|
|
111
|
+
return (
|
|
112
|
+
pd.Series(
|
|
113
|
+
dds.obs['size_factors'].to_numpy(copy = True),
|
|
114
|
+
index = counts.index,
|
|
115
|
+
),
|
|
116
|
+
effective_fit_type,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _validate_options(*, scope: str, fit_type: str, n_cpus: int | None) -> None:
|
|
121
|
+
if not isinstance(scope, str) or scope not in _SCOPES:
|
|
122
|
+
expected = ', '.join(sorted(_SCOPES))
|
|
123
|
+
raise ModelFitError(f'scope must be one of: {expected}.')
|
|
124
|
+
if not isinstance(fit_type, str) or fit_type not in _FIT_TYPES:
|
|
125
|
+
expected = ', '.join(sorted(_FIT_TYPES))
|
|
126
|
+
raise ModelFitError(f'fit_type must be one of: {expected}.')
|
|
127
|
+
if n_cpus is not None and (
|
|
128
|
+
isinstance(n_cpus, bool)
|
|
129
|
+
or not isinstance(n_cpus, int)
|
|
130
|
+
or n_cpus < 1
|
|
131
|
+
):
|
|
132
|
+
raise ModelFitError('n_cpus must be None or a positive integer.')
|
monopoly/_provenance.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import platform
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from monopoly._design import DesignMatrices
|
|
12
|
+
from monopoly.dataset import MonopolyDataset
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_provenance(
|
|
16
|
+
dataset: MonopolyDataset,
|
|
17
|
+
design: DesignMatrices,
|
|
18
|
+
*,
|
|
19
|
+
fit: dict[str, Any],
|
|
20
|
+
contrast: dict[str, Any],
|
|
21
|
+
) -> dict[str, Any]:
|
|
22
|
+
"""Build a reproducible analysis record."""
|
|
23
|
+
return {
|
|
24
|
+
'software': {
|
|
25
|
+
'monopolyPolysome': _package_version('monopoly-polysome'),
|
|
26
|
+
'formulaic': _package_version('formulaic'),
|
|
27
|
+
'numpy': _package_version('numpy'),
|
|
28
|
+
'pandas': _package_version('pandas'),
|
|
29
|
+
'pydeseq2': _package_version('pydeseq2'),
|
|
30
|
+
'python': platform.python_version(),
|
|
31
|
+
},
|
|
32
|
+
'inputs': {
|
|
33
|
+
'participants': len(dataset.participants),
|
|
34
|
+
'genes': len(dataset.genes),
|
|
35
|
+
'fingerprints': _dataset_fingerprints(dataset),
|
|
36
|
+
},
|
|
37
|
+
'design': {
|
|
38
|
+
'reference': design.reference,
|
|
39
|
+
'test': design.test,
|
|
40
|
+
'covariates': list(design.covariates),
|
|
41
|
+
'fullFormula': design.full_formula,
|
|
42
|
+
'reducedFormula': design.reduced_formula,
|
|
43
|
+
'fullColumns': design.full.columns.tolist(),
|
|
44
|
+
'residualDegreesOfFreedom': design.residual_degrees_of_freedom,
|
|
45
|
+
},
|
|
46
|
+
'fit': fit.copy(),
|
|
47
|
+
'contrast': contrast.copy(),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _dataset_fingerprints(dataset: MonopolyDataset) -> dict[str, str | None]:
|
|
52
|
+
return {
|
|
53
|
+
fraction: _frame_fingerprint(dataset._counts[fraction])
|
|
54
|
+
for fraction in ('input', 'monosome', 'polysome')
|
|
55
|
+
} | {
|
|
56
|
+
'metadata': _frame_fingerprint(dataset._metadata),
|
|
57
|
+
'libraryMetadata': _frame_fingerprint(dataset._library_metadata),
|
|
58
|
+
'geneMetadata': (
|
|
59
|
+
None
|
|
60
|
+
if dataset._gene_metadata is None
|
|
61
|
+
else _frame_fingerprint(dataset._gene_metadata)
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _frame_fingerprint(frame: pd.DataFrame) -> str:
|
|
67
|
+
digest = hashlib.sha256()
|
|
68
|
+
digest.update(str(frame.shape).encode())
|
|
69
|
+
digest.update('\0'.join(map(str, frame.dtypes)).encode())
|
|
70
|
+
digest.update(pd.util.hash_pandas_object(frame.index).values.tobytes())
|
|
71
|
+
digest.update(pd.util.hash_pandas_object(frame.columns).values.tobytes())
|
|
72
|
+
try:
|
|
73
|
+
values = pd.util.hash_pandas_object(frame, index = True).values
|
|
74
|
+
except TypeError:
|
|
75
|
+
values = pd.util.hash_pandas_object(
|
|
76
|
+
frame.map(repr),
|
|
77
|
+
index = True,
|
|
78
|
+
).values
|
|
79
|
+
digest.update(values.tobytes())
|
|
80
|
+
return f'sha256:{digest.hexdigest()}'
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _package_version(distribution: str) -> str:
|
|
84
|
+
try:
|
|
85
|
+
return version(distribution)
|
|
86
|
+
except PackageNotFoundError: # pragma: no cover
|
|
87
|
+
return 'unknown'
|
monopoly/_validation.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Collection
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from monopoly.exceptions import ModelFitError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_control_genes(
|
|
11
|
+
control_genes: Collection[str] | None,
|
|
12
|
+
*,
|
|
13
|
+
genes: pd.Index,
|
|
14
|
+
) -> list[str] | None:
|
|
15
|
+
"""Validate control genes and restore canonical gene order."""
|
|
16
|
+
if control_genes is None:
|
|
17
|
+
return None
|
|
18
|
+
if isinstance(control_genes, str):
|
|
19
|
+
raise ModelFitError('control_genes must be a collection of gene identifiers.')
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
controls = list(control_genes)
|
|
23
|
+
except TypeError as error:
|
|
24
|
+
raise ModelFitError(
|
|
25
|
+
'control_genes must be a collection of gene identifiers.',
|
|
26
|
+
) from error
|
|
27
|
+
if not controls:
|
|
28
|
+
raise ModelFitError('control_genes cannot be empty.')
|
|
29
|
+
if any(not isinstance(gene, str) or not gene.strip() for gene in controls):
|
|
30
|
+
raise ModelFitError('control_genes must contain nonempty strings.')
|
|
31
|
+
if len(controls) != len(set(controls)):
|
|
32
|
+
raise ModelFitError('control_genes cannot contain duplicates.')
|
|
33
|
+
unknown = pd.Index(controls).difference(genes)
|
|
34
|
+
if not unknown.empty:
|
|
35
|
+
raise ModelFitError(f'Unknown control_genes: {unknown.tolist()}.')
|
|
36
|
+
|
|
37
|
+
control_set = set(controls)
|
|
38
|
+
return [gene for gene in genes if gene in control_set]
|