combatlearn 1.4.0__tar.gz → 2.1.0__tar.gz

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.
Files changed (29) hide show
  1. {combatlearn-1.4.0 → combatlearn-2.1.0}/PKG-INFO +13 -6
  2. {combatlearn-1.4.0 → combatlearn-2.1.0}/README.md +12 -6
  3. {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn/__init__.py +1 -1
  4. combatlearn-2.1.0/combatlearn/_mixed.py +165 -0
  5. combatlearn-2.1.0/combatlearn/_utils.py +21 -0
  6. {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn/core.py +243 -10
  7. combatlearn-2.1.0/combatlearn/inspection.py +194 -0
  8. {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn/metrics.py +127 -214
  9. {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn/sklearn_api.py +50 -132
  10. combatlearn-2.1.0/combatlearn/visualization.py +636 -0
  11. {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn.egg-info/PKG-INFO +13 -6
  12. {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn.egg-info/SOURCES.txt +8 -1
  13. {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn.egg-info/requires.txt +1 -0
  14. {combatlearn-1.4.0 → combatlearn-2.1.0}/requirements-dev.txt +1 -0
  15. combatlearn-2.1.0/tests/test_combat.py +477 -0
  16. combatlearn-2.1.0/tests/test_inspection.py +235 -0
  17. combatlearn-2.1.0/tests/test_longitudinal.py +204 -0
  18. combatlearn-2.1.0/tests/test_metrics.py +107 -0
  19. combatlearn-2.1.0/tests/test_visualization.py +180 -0
  20. combatlearn-1.4.0/combatlearn/visualization.py +0 -609
  21. combatlearn-1.4.0/tests/test_combat.py +0 -1008
  22. {combatlearn-1.4.0 → combatlearn-2.1.0}/LICENSE +0 -0
  23. {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn/py.typed +0 -0
  24. {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn.egg-info/dependency_links.txt +0 -0
  25. {combatlearn-1.4.0 → combatlearn-2.1.0}/combatlearn.egg-info/top_level.txt +0 -0
  26. {combatlearn-1.4.0 → combatlearn-2.1.0}/pyproject.toml +0 -0
  27. {combatlearn-1.4.0 → combatlearn-2.1.0}/requirements-docs.txt +0 -0
  28. {combatlearn-1.4.0 → combatlearn-2.1.0}/requirements.txt +0 -0
  29. {combatlearn-1.4.0 → combatlearn-2.1.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: combatlearn
3
- Version: 1.4.0
3
+ Version: 2.1.0
4
4
  Summary: Batch-effect harmonization for machine learning frameworks.
5
5
  Author-email: Ettore Rocchi <ettoreroc@gmail.com>
6
6
  License: MIT
@@ -27,6 +27,7 @@ Requires-Dist: ruff>=0.8; extra == "dev"
27
27
  Requires-Dist: pre-commit>=3.0; extra == "dev"
28
28
  Requires-Dist: mypy>=1.0; extra == "dev"
29
29
  Requires-Dist: pandas-stubs; extra == "dev"
30
+ Requires-Dist: statsmodels>=0.14; extra == "dev"
30
31
  Provides-Extra: docs
31
32
  Requires-Dist: sphinx>=7.0.0; extra == "docs"
32
33
  Requires-Dist: pydata-sphinx-theme>=0.15.0; extra == "docs"
@@ -49,10 +50,11 @@ Dynamic: license-file
49
50
 
50
51
  **combatlearn** makes the popular _ComBat_ (and _CovBat_) batch-effect correction algorithm available for use into machine learning frameworks. It lets you harmonise high-dimensional data inside a scikit-learn `Pipeline`, so that cross-validation and grid-search automatically take batch structure into account, **without data leakage**.
51
52
 
52
- **Three methods**:
53
+ **Four methods**:
53
54
  - `method="johnson"` - classic ComBat (Johnson _et al._, 2007)
54
55
  - `method="fortin"` - neuroComBat (Fortin _et al._, 2018)
55
56
  - `method="chen"` - CovBat (Chen _et al._, 2022)
57
+ - `method="longitudinal"` - Longitudinal ComBat for repeated measures (Beer _et al._, 2020)
56
58
 
57
59
  ## Installation
58
60
 
@@ -75,6 +77,7 @@ import pandas as pd
75
77
  from sklearn.pipeline import Pipeline
76
78
  from sklearn.preprocessing import StandardScaler
77
79
  from sklearn.linear_model import LogisticRegression
80
+ from sklearn.model_selection import GridSearchCV
78
81
  from combatlearn import ComBat
79
82
 
80
83
  df = pd.read_csv("data.csv", index_col=0)
@@ -125,18 +128,20 @@ The following section provides a detailed explanation of all parameters availabl
125
128
  | Parameter | Type | Default | Description |
126
129
  | --- | --- | --- | --- |
127
130
  | `batch` | array-like or pd.Series | **required** | Vector indicating batch assignment for each sample. This is used to estimate and remove batch effects. |
128
- | `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"` and `"chen"` methods. |
129
- | `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"` and `"chen"` methods. |
131
+ | `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"`, `"chen"`, and `"longitudinal"` methods. |
132
+ | `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"`, `"chen"`, and `"longitudinal"` methods. |
133
+ | `subject_id` | array-like or pd.Series | `None` | Subject/individual labels for the random intercept. **Required** for `method="longitudinal"`, ignored otherwise. |
134
+ | `time_covariate` | array-like or pd.Series | `None` | Optional continuous time variable for repeated measures. Only used in `"longitudinal"`. |
130
135
 
131
136
  ### Algorithm Options
132
137
 
133
138
  | Parameter | Type | Default | Description |
134
139
  | --- | --- | --- | --- |
135
- | `method` | str | `"johnson"` | ComBat method to use: <ul><li>`"johnson"` - Classical ComBat (_Johnson et al. 2007_)</li><li>`"fortin"` - ComBat with covariates (_Fortin et al. 2018_)</li><li>`"chen"` - CovBat, PCA-based correction (_Chen et al. 2022_)</li></ul> |
140
+ | `method` | str | `"johnson"` | ComBat method to use: <ul><li>`"johnson"` - Classical ComBat (_Johnson et al. 2007_)</li><li>`"fortin"` - ComBat with covariates (_Fortin et al. 2018_)</li><li>`"chen"` - CovBat, PCA-based correction (_Chen et al. 2022_)</li><li>`"longitudinal"` - Longitudinal ComBat with a per-subject random intercept (_Beer et al. 2020_)</li></ul> |
136
141
  | `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
137
142
  | `mean_only` | bool | `False` | If `True`, only the **mean** is corrected, while variances are left unchanged. Useful for preserving variance structure in the data. |
138
143
  | `reference_batch` | str or `None` | `None` | If specified, acts as a reference batch - other batches will be corrected to match this one. |
139
- | `covbat_cov_thresh` | float, int | `0.9` | For `"chen"` method only: Cumulative variance threshold $]0,1[$ to retain PCs in PCA space (e.g., 0.9 = retain 90% explained variance). If an integer is provided, it represents the number of principal components to use. |
144
+ | `covbat_cov_thresh` | float, int | `0.9` | For `"chen"` method only: Cumulative variance threshold in `(0, 1]` to retain PCs in PCA space (e.g., 0.9 = retain 90% explained variance). If an integer is provided, it represents the number of principal components to use. |
140
145
  | `eps` | float | `1e-8` | Small jitter value added to variances to prevent divide-by-zero errors during standardization. |
141
146
 
142
147
 
@@ -190,3 +195,5 @@ Please consider citing the original papers:
190
195
  - [**neuroCombat**](https://github.com/Jfortin1/neuroCombat) - Fortin JP et al. _Neuroimage_. 2018. doi: [10.1016/j.neuroimage.2017.11.024](https://doi.org/10.1016/j.neuroimage.2017.11.024)
191
196
 
192
197
  - [**CovBat**](https://github.com/andy1764/CovBat_Harmonization) - Chen AA et al. _Hum Brain Mapp_. 2022. doi: [10.1002/hbm.25688](https://doi.org/10.1002/hbm.25688)
198
+
199
+ - [**Longitudinal ComBat**](https://github.com/jcbeer/longCombat) - Beer JC et al. _Neuroimage_. 2020. doi: [10.1016/j.neuroimage.2020.117129](https://doi.org/10.1016/j.neuroimage.2020.117129)
@@ -12,10 +12,11 @@
12
12
 
13
13
  **combatlearn** makes the popular _ComBat_ (and _CovBat_) batch-effect correction algorithm available for use into machine learning frameworks. It lets you harmonise high-dimensional data inside a scikit-learn `Pipeline`, so that cross-validation and grid-search automatically take batch structure into account, **without data leakage**.
14
14
 
15
- **Three methods**:
15
+ **Four methods**:
16
16
  - `method="johnson"` - classic ComBat (Johnson _et al._, 2007)
17
17
  - `method="fortin"` - neuroComBat (Fortin _et al._, 2018)
18
18
  - `method="chen"` - CovBat (Chen _et al._, 2022)
19
+ - `method="longitudinal"` - Longitudinal ComBat for repeated measures (Beer _et al._, 2020)
19
20
 
20
21
  ## Installation
21
22
 
@@ -38,6 +39,7 @@ import pandas as pd
38
39
  from sklearn.pipeline import Pipeline
39
40
  from sklearn.preprocessing import StandardScaler
40
41
  from sklearn.linear_model import LogisticRegression
42
+ from sklearn.model_selection import GridSearchCV
41
43
  from combatlearn import ComBat
42
44
 
43
45
  df = pd.read_csv("data.csv", index_col=0)
@@ -88,18 +90,20 @@ The following section provides a detailed explanation of all parameters availabl
88
90
  | Parameter | Type | Default | Description |
89
91
  | --- | --- | --- | --- |
90
92
  | `batch` | array-like or pd.Series | **required** | Vector indicating batch assignment for each sample. This is used to estimate and remove batch effects. |
91
- | `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"` and `"chen"` methods. |
92
- | `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"` and `"chen"` methods. |
93
+ | `discrete_covariates` | array-like, pd.Series, or pd.DataFrame | `None` | Optional categorical covariates (e.g., sex, site). Only used in `"fortin"`, `"chen"`, and `"longitudinal"` methods. |
94
+ | `continuous_covariates` | array-like, pd.Series or pd.DataFrame | `None` | Optional continuous covariates (e.g., age). Only used in `"fortin"`, `"chen"`, and `"longitudinal"` methods. |
95
+ | `subject_id` | array-like or pd.Series | `None` | Subject/individual labels for the random intercept. **Required** for `method="longitudinal"`, ignored otherwise. |
96
+ | `time_covariate` | array-like or pd.Series | `None` | Optional continuous time variable for repeated measures. Only used in `"longitudinal"`. |
93
97
 
94
98
  ### Algorithm Options
95
99
 
96
100
  | Parameter | Type | Default | Description |
97
101
  | --- | --- | --- | --- |
98
- | `method` | str | `"johnson"` | ComBat method to use: <ul><li>`"johnson"` - Classical ComBat (_Johnson et al. 2007_)</li><li>`"fortin"` - ComBat with covariates (_Fortin et al. 2018_)</li><li>`"chen"` - CovBat, PCA-based correction (_Chen et al. 2022_)</li></ul> |
102
+ | `method` | str | `"johnson"` | ComBat method to use: <ul><li>`"johnson"` - Classical ComBat (_Johnson et al. 2007_)</li><li>`"fortin"` - ComBat with covariates (_Fortin et al. 2018_)</li><li>`"chen"` - CovBat, PCA-based correction (_Chen et al. 2022_)</li><li>`"longitudinal"` - Longitudinal ComBat with a per-subject random intercept (_Beer et al. 2020_)</li></ul> |
99
103
  | `parametric` | bool | `True` | Whether to use the **parametric empirical Bayes** formulation. If `False`, a non-parametric iterative scheme is used. |
100
104
  | `mean_only` | bool | `False` | If `True`, only the **mean** is corrected, while variances are left unchanged. Useful for preserving variance structure in the data. |
101
105
  | `reference_batch` | str or `None` | `None` | If specified, acts as a reference batch - other batches will be corrected to match this one. |
102
- | `covbat_cov_thresh` | float, int | `0.9` | For `"chen"` method only: Cumulative variance threshold $]0,1[$ to retain PCs in PCA space (e.g., 0.9 = retain 90% explained variance). If an integer is provided, it represents the number of principal components to use. |
106
+ | `covbat_cov_thresh` | float, int | `0.9` | For `"chen"` method only: Cumulative variance threshold in `(0, 1]` to retain PCs in PCA space (e.g., 0.9 = retain 90% explained variance). If an integer is provided, it represents the number of principal components to use. |
103
107
  | `eps` | float | `1e-8` | Small jitter value added to variances to prevent divide-by-zero errors during standardization. |
104
108
 
105
109
 
@@ -152,4 +156,6 @@ Please consider citing the original papers:
152
156
 
153
157
  - [**neuroCombat**](https://github.com/Jfortin1/neuroCombat) - Fortin JP et al. _Neuroimage_. 2018. doi: [10.1016/j.neuroimage.2017.11.024](https://doi.org/10.1016/j.neuroimage.2017.11.024)
154
158
 
155
- - [**CovBat**](https://github.com/andy1764/CovBat_Harmonization) - Chen AA et al. _Hum Brain Mapp_. 2022. doi: [10.1002/hbm.25688](https://doi.org/10.1002/hbm.25688)
159
+ - [**CovBat**](https://github.com/andy1764/CovBat_Harmonization) - Chen AA et al. _Hum Brain Mapp_. 2022. doi: [10.1002/hbm.25688](https://doi.org/10.1002/hbm.25688)
160
+
161
+ - [**Longitudinal ComBat**](https://github.com/jcbeer/longCombat) - Beer JC et al. _Neuroimage_. 2020. doi: [10.1016/j.neuroimage.2020.117129](https://doi.org/10.1016/j.neuroimage.2020.117129)
@@ -1,5 +1,5 @@
1
1
  from .sklearn_api import ComBat
2
2
 
3
3
  __all__ = ["ComBat"]
4
- __version__ = "1.4.0"
4
+ __version__ = "2.1.0"
5
5
  __author__ = "Ettore Rocchi"
@@ -0,0 +1,165 @@
1
+ """Random-intercept linear mixed model fitting for Longitudinal ComBat.
2
+
3
+ Fits, for every feature (column of ``Y``) simultaneously, the model
4
+
5
+ y = X @ beta + Z @ u + eps, u ~ N(0, tau^2 I), eps ~ N(0, sigma^2 I)
6
+
7
+ where ``Z`` is the subject (random-intercept) indicator matrix. Estimation is
8
+ by REML, profiling out ``beta`` and ``sigma^2`` so that only the scalar
9
+ variance ratio ``lambda = tau^2 / sigma^2`` is optimized per feature.
10
+
11
+ The single random-intercept grouping makes the marginal covariance
12
+ ``V = sigma^2 (I + lambda Z Z^T)`` block diagonal (one block per subject), so
13
+ ``V^{-1}`` is available in closed form via Sherman-Morrison and every operation
14
+ is O(n) per feature. The variance ratio is found by a vectorized grid search
15
+ over ``lambda`` followed by a parabolic refinement, after which the fixed
16
+ effects, residual variance, and random-intercept BLUPs are computed in a single
17
+ per-feature pass.
18
+
19
+ This keeps Longitudinal ComBat dependency-free (numpy only); correctness is
20
+ cross-checked against ``statsmodels.MixedLM`` in the test suite.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import numpy as np
26
+ import numpy.typing as npt
27
+
28
+ FloatArray = npt.NDArray[np.float64]
29
+ IntArray = npt.NDArray[np.intp]
30
+
31
+
32
+ def _group_sums(M: FloatArray, group_idx: IntArray, n_groups: int) -> FloatArray:
33
+ """Sum the rows of ``M`` (n, c) within each group, returning (n_groups, c)."""
34
+ out = np.zeros((n_groups, M.shape[1]), dtype=np.float64)
35
+ np.add.at(out, group_idx, M)
36
+ return out
37
+
38
+
39
+ def _w_apply(M: FloatArray, factor: FloatArray, group_idx: IntArray, n_groups: int) -> FloatArray:
40
+ """Apply ``W = (I + lambda Z Z^T)^{-1}`` to the columns of ``M``.
41
+
42
+ ``factor[k] = lambda / (1 + lambda * n_k)`` is the Sherman-Morrison
43
+ per-group correction (``n_k`` = number of observations in group ``k``).
44
+ """
45
+ gs = _group_sums(M, group_idx, n_groups)
46
+ return M - factor[group_idx][:, None] * gs[group_idx]
47
+
48
+
49
+ def _reml_objective(
50
+ lam: float,
51
+ X: FloatArray,
52
+ Y: FloatArray,
53
+ group_idx: IntArray,
54
+ n_groups: int,
55
+ n_k: FloatArray,
56
+ n: int,
57
+ p: int,
58
+ ridge: float,
59
+ ) -> FloatArray:
60
+ """Profiled REML objective (to minimize) evaluated for all features at ``lam``."""
61
+ factor = lam / (1.0 + lam * n_k)
62
+ WX = _w_apply(X, factor, group_idx, n_groups)
63
+ A = X.T @ WX
64
+ A.flat[:: p + 1] += ridge
65
+ beta = np.linalg.solve(A, X.T @ _w_apply(Y, factor, group_idx, n_groups))
66
+ R = Y - X @ beta
67
+ WR = _w_apply(R, factor, group_idx, n_groups)
68
+ rss = np.maximum(np.einsum("ng,ng->g", R, WR), 1e-12)
69
+ logdet_v = float(np.sum(np.log1p(lam * n_k)))
70
+ _, logdet_a = np.linalg.slogdet(A)
71
+ result = logdet_v + (n - p) * np.log(rss) + float(logdet_a)
72
+ return np.asarray(result, dtype=np.float64)
73
+
74
+
75
+ def fit_random_intercept(
76
+ X: FloatArray,
77
+ Y: FloatArray,
78
+ group_idx: IntArray,
79
+ n_groups: int,
80
+ n_k: FloatArray,
81
+ *,
82
+ eps: float = 1e-8,
83
+ n_grid: int = 80,
84
+ ) -> tuple[FloatArray, FloatArray, FloatArray, FloatArray]:
85
+ """Fit a random-intercept LME per feature by REML.
86
+
87
+ Parameters
88
+ ----------
89
+ X : ndarray of shape (n, p)
90
+ Fixed-effects design matrix (shared across features).
91
+ Y : ndarray of shape (n, n_features)
92
+ Response matrix; one column per feature.
93
+ group_idx : ndarray of shape (n,)
94
+ Integer group (subject) code in ``[0, n_groups)`` for each observation.
95
+ n_groups : int
96
+ Number of distinct subjects.
97
+ n_k : ndarray of shape (n_groups,)
98
+ Number of observations per subject.
99
+ eps : float, default=1e-8
100
+ Numerical jitter; also scales the diagonal ridge on ``X^T W X``.
101
+ n_grid : int, default=80
102
+ Number of log-spaced ``lambda`` grid points for the variance-ratio search.
103
+
104
+ Returns
105
+ -------
106
+ beta : ndarray of shape (p, n_features)
107
+ Estimated fixed-effects coefficients.
108
+ blup : ndarray of shape (n_groups, n_features)
109
+ Best linear unbiased predictors of the subject random intercepts.
110
+ sigma2 : ndarray of shape (n_features,)
111
+ Estimated residual variance per feature.
112
+ lam_opt : ndarray of shape (n_features,)
113
+ Estimated variance ratio ``tau^2 / sigma^2`` per feature.
114
+ """
115
+ X = np.asarray(X, dtype=np.float64)
116
+ Y = np.asarray(Y, dtype=np.float64)
117
+ group_idx = np.asarray(group_idx, dtype=np.intp)
118
+ n_k = np.asarray(n_k, dtype=np.float64)
119
+ n, p = X.shape
120
+ n_features = Y.shape[1]
121
+ ridge = eps * float(np.trace(X.T @ X)) / max(p, 1)
122
+
123
+ # Vectorized grid search for the variance ratio (per feature).
124
+ log_grid = np.linspace(-4.0, 4.0, n_grid)
125
+ lam_grid = np.concatenate([[0.0], 10.0**log_grid])
126
+ fvals = np.empty((lam_grid.size, n_features), dtype=np.float64)
127
+ for i, lam in enumerate(lam_grid):
128
+ fvals[i] = _reml_objective(lam, X, Y, group_idx, n_groups, n_k, n, p, ridge)
129
+ best = np.argmin(fvals, axis=0)
130
+ lam_opt = lam_grid[best].astype(np.float64)
131
+
132
+ # Vectorized parabolic refinement (log10 space) for interior positive optima.
133
+ interior = (best >= 2) & (best <= n_grid - 1)
134
+ if np.any(interior):
135
+ bi = best[interior]
136
+ f_lo = fvals[bi - 1, interior]
137
+ f0 = fvals[bi, interior]
138
+ f_hi = fvals[bi + 1, interior]
139
+ denom = f_lo - 2.0 * f0 + f_hi
140
+ delta = np.where(np.abs(denom) > 1e-12, 0.5 * (f_lo - f_hi) / denom, 0.0)
141
+ delta = np.clip(delta, -1.0, 1.0)
142
+ step = log_grid[1] - log_grid[0]
143
+ lam_opt[interior] = 10.0 ** (log_grid[bi - 1] + delta * step)
144
+
145
+ # Final per-feature fixed effects, residual variance, and random-intercept BLUPs.
146
+ beta = np.empty((p, n_features), dtype=np.float64)
147
+ sigma2 = np.empty(n_features, dtype=np.float64)
148
+ blup = np.zeros((n_groups, n_features), dtype=np.float64)
149
+ denom_sigma = max(n - p, 1)
150
+ for g in range(n_features):
151
+ lam = float(lam_opt[g])
152
+ factor = lam / (1.0 + lam * n_k)
153
+ A = X.T @ _w_apply(X, factor, group_idx, n_groups)
154
+ A.flat[:: p + 1] += ridge
155
+ y = Y[:, g : g + 1]
156
+ b = np.linalg.solve(A, X.T @ _w_apply(y, factor, group_idx, n_groups))[:, 0]
157
+ beta[:, g] = b
158
+ r = Y[:, g] - X @ b
159
+ Wr = _w_apply(r[:, None], factor, group_idx, n_groups)[:, 0]
160
+ sigma2[g] = float(r @ Wr) / denom_sigma
161
+ s_k = np.zeros(n_groups, dtype=np.float64)
162
+ np.add.at(s_k, group_idx, r)
163
+ blup[:, g] = lam * s_k / (1.0 + lam * n_k)
164
+
165
+ return beta, blup, sigma2, lam_opt
@@ -0,0 +1,21 @@
1
+ """Internal utilities shared across combatlearn modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ from .core import ArrayLike
9
+
10
+
11
+ def _subset(obj: ArrayLike | None, idx: pd.Index) -> pd.DataFrame | pd.Series | None:
12
+ """Subset array-like object by index."""
13
+ if obj is None:
14
+ return None
15
+ if isinstance(obj, pd.Series | pd.DataFrame):
16
+ return obj.loc[idx]
17
+ else:
18
+ if isinstance(obj, np.ndarray) and obj.ndim == 1:
19
+ return pd.Series(obj, index=idx)
20
+ else:
21
+ return pd.DataFrame(obj, index=idx)