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 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.')
@@ -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'
@@ -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]