Skip to content

Commit f7cbba6

Browse files
Merge pull request #144 from scikit-learn-contrib/mcar-test-implementation
Mcar test implementation
2 parents 7bd8d6c + ae31bd6 commit f7cbba6

File tree

12 files changed

+430
-10
lines changed

12 files changed

+430
-10
lines changed

HISTORY.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
History
33
=======
44

5+
0.1.7 (2024-06-13)
6+
------------------
7+
* Little's test implemented in a new hole_characterization module
8+
* Documentation now includes an analysis section with a tutorial
9+
* Hole generators now provide reproducible outputs
10+
511
0.1.5 (2024-04-17)
612
------------------
713

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
coverage:
2-
pytest --cov-branch --cov=qolmat --cov-report=xml
2+
pytest --cov-branch --cov=qolmat --cov-report=xml tests
33

44
doctest:
55
pytest --doctest-modules --pyargs qolmat

docs/analysis.rst

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
2+
Analysis
3+
========
4+
This section gives a better understanding of the holes in a dataset.
5+
6+
1. General approach
7+
-------------------
8+
9+
As described in section :ref:`hole_generator`, there are 3 main types of missing data mechanism: MCAR, MAR and MNAR.
10+
The analysis module provides tools to characterize the type of holes.
11+
12+
The MNAR case is the trickiest, the user must first consider whether their missing data mechanism is MNAR. In the meantime, we make assume that the missing-data mechanism is ignorable (ie., it is not MNAR). If an MNAR mechanism is suspected, please see this article :ref:`An approach to test for MNAR [1]<Noonan-article>` for relevant actions.
13+
14+
Then Qolmat proposes a test to determine whether the missing data mechanism is MCAR or MAR.
15+
16+
2. How to use the results
17+
-------------------------
18+
19+
At the end of the MCAR test, it can then be assumed whether the missing data mechanism is MCAR or not. This serves three differents purposes:
20+
21+
a. Diagnosis
22+
^^^^^^^^^^^^
23+
24+
If the result of the MCAR test is "The MCAR hypothesis is rejected", we can then ask ourselves over which range of values holes are more present.
25+
The test result can then be used for continuous data quality management.
26+
27+
b. Estimation
28+
^^^^^^^^^^^^^
29+
30+
Some estimation methods are not suitable for the MAR case. For example, dropping the nans introduces bias into the estimator, it is necessary to have validated that the missing-data mechanism is MCAR.
31+
32+
c. Imputation
33+
^^^^^^^^^^^^^
34+
35+
Qolmat allows model selection imputation algorithms. For each of the K folds, Qolmat artificially masks a set of observed values using a default or user-specified hole generator. It seems natural to create these masks according to the same missing-data mechanism as determined by the test. Here is the documentation on using Qolmat for imputation `model selection <https://qolmat.readthedocs.io/en/latest/#:~:text=How%20does%20Qolmat%20work%20%3F>`_.
36+
37+
3. The MCAR Tests
38+
-----------------
39+
40+
There are several statistical tests to determine if the missing data mechanism is MCAR or MAR. Most tests are based on the notion of missing pattern.
41+
A missing pattern, also called a pattern, is the structure of observed and missing values in a dataset. For example, for a dataset with two columns, the possible patterns are: (0, 0), (1, 0), (0, 1), (1, 1). The value 1 indicates that the value in the column is missing.
42+
43+
The MCAR missing-data mechanism means that there is independence between the presence of holes and the observed values. In other words, the data distribution is the same for all patterns.
44+
45+
a. Little's Test
46+
^^^^^^^^^^^^^^^^
47+
48+
The best-known MCAR test is the :ref:`Little [2]<Little-article>` test, and it has been implemented in :class:`LittleTest`. Keep in mind that the Little's test is designed to test the homogeneity of means across the missing patterns and won't be efficient to detect the heterogeneity of covariance accross missing patterns.
49+
50+
b. PKLM Test
51+
^^^^^^^^^^^^
52+
53+
The :ref:`PKLM [2]<PKLM-article>` (Projected Kullback-Leibler MCAR) test compares the distributions of different missing patterns on random projections in the variable space of the data. This recent test applies to mixed-type data. It is not implemented yet in Qolmat.
54+
55+
References
56+
----------
57+
58+
.. _Noonan-article:
59+
60+
[1] Noonan, Jack, et al. `An integrated approach to test for missing not at random. <https://arxiv.org/abs/2208.07813>`_ arXiv preprint arXiv:2208.07813 (2022).
61+
62+
.. _Little-article:
63+
64+
[2] Little, R. J. A. `A Test of Missing Completely at Random for Multivariate Data with Missing Values. <https://www.tandfonline.com/doi/abs/10.1080/01621459.1988.10478722>`_ Journal of the American Statistical Association, Volume 83, 1988 - Issue 404.
65+
66+
.. _PKLM-article:
67+
68+
[3] Spohn, Meta-Lina, et al. `PKLM: A flexible MCAR test using Classification. <https://arxiv.org/abs/2109.10150>`_ arXiv preprint arXiv:2109.10150 (2021).

docs/index.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,11 @@
2525
:caption: API
2626

2727
api
28+
29+
.. toctree::
30+
:maxdepth: 2
31+
:hidden:
32+
:caption: ANALYSIS
33+
34+
analysis
35+
examples/tutorials/plot_tuto_mcar

examples/RPCA.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,6 @@ plt.show()
199199

200200
```python
201201
%%time
202-
# rpca_noisy = RpcaNoisy(period=10, tau=1, lam=0.4, rank=2, list_periods=[10], list_etas=[0.01], norm="L2")
203202
rpca_noisy = RpcaNoisy(tau=1, lam=0.4, rank=2, norm="L2")
204203
M, A = rpca_noisy.decompose(D, Omega)
205204
# imputed = X

examples/tutorials/plot_tuto_hole_generator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ def plot_cdf(
282282

283283

284284
# %%
285-
# d. Grouped Hole Generator
285+
# e. Grouped Hole Generator
286286
# ***************************************************************
287287
# The holes are generated according to the groups defined by the user.
288288
# This metohd is implemented in the
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""
2+
============================================
3+
Tutorial for Testing the MCAR Case
4+
============================================
5+
6+
In this tutorial, we show how to test the MCAR case using the Little's test.
7+
"""
8+
9+
# %%
10+
# First import some libraries
11+
from matplotlib import pyplot as plt
12+
13+
import numpy as np
14+
import pandas as pd
15+
from scipy.stats import norm
16+
17+
from qolmat.analysis.holes_characterization import LittleTest
18+
from qolmat.benchmark.missing_patterns import UniformHoleGenerator
19+
20+
plt.rcParams.update({"font.size": 12})
21+
22+
23+
# %%
24+
# Generating random data
25+
# ----------------------
26+
27+
rng = np.random.RandomState(42)
28+
data = rng.multivariate_normal(mean=[0, 0], cov=[[1, 0], [0, 1]], size=200)
29+
df = pd.DataFrame(data=data, columns=["Column 1", "Column 2"])
30+
31+
q975 = norm.ppf(0.975)
32+
33+
# %%
34+
# The Little's test
35+
# ---------------------------------------------------------------
36+
# First, we need to introduce the concept of a missing pattern. A missing pattern, also called a
37+
# pattern, is the structure of observed and missing values in a dataset. For example, in a
38+
# dataset with two columns, the possible patterns are: (0, 0), (1, 0), (0, 1), (1, 1). The value 1
39+
# (0) indicates that the column value is missing (observed).
40+
#
41+
# The null hypothesis, H0, is: "The means of observations within each pattern are similar.".
42+
#
43+
# We choose to use the classic threshold of 5%. If the test p-value is below this threshold,
44+
# we reject the null hypothesis.
45+
#
46+
# This notebook shows how the Little's test performs on a simplistic case and its limitations. We
47+
# instanciate a test object with a random state for reproducibility.
48+
49+
test_mcar = LittleTest(random_state=rng)
50+
51+
# %%
52+
# Case 1: MCAR holes (True negative)
53+
# ==================================
54+
55+
56+
hole_gen = UniformHoleGenerator(
57+
n_splits=1, random_state=rng, subset=["Column 2"], ratio_masked=0.2
58+
)
59+
df_mask = hole_gen.generate_mask(df)
60+
df_nan = df.where(~df_mask, np.nan)
61+
62+
has_nan = df_mask.any(axis=1)
63+
df_observed = df.loc[~has_nan]
64+
df_hidden = df.loc[has_nan]
65+
66+
plt.scatter(df_observed["Column 1"], df_observed[["Column 2"]], label="Fully observed values")
67+
plt.scatter(df_hidden[["Column 1"]], df_hidden[["Column 2"]], label="Values with missing C2")
68+
69+
plt.legend(
70+
loc="lower left",
71+
fontsize=8,
72+
)
73+
plt.xlabel("Column 1")
74+
plt.ylabel("Column 2")
75+
plt.title("Case 1: MCAR missingness mechanism")
76+
plt.grid()
77+
plt.show()
78+
79+
# %%
80+
result = test_mcar.test(df_nan)
81+
print(f"Test p-value: {result:.2%}")
82+
# %%
83+
# The p-value is quite high, therefore we don't reject H0.
84+
# We can then suppose that our missingness mechanism is MCAR.
85+
86+
# %%
87+
# Case 2: MAR holes with mean bias (True positive)
88+
# ================================================
89+
90+
df_mask = pd.DataFrame({"Column 1": False, "Column 2": df["Column 1"] > q975}, index=df.index)
91+
92+
df_nan = df.where(~df_mask, np.nan)
93+
94+
has_nan = df_mask.any(axis=1)
95+
df_observed = df.loc[~has_nan]
96+
df_hidden = df.loc[has_nan]
97+
98+
plt.scatter(df_observed["Column 1"], df_observed[["Column 2"]], label="Fully observed values")
99+
plt.scatter(df_hidden[["Column 1"]], df_hidden[["Column 2"]], label="Values with missing C2")
100+
101+
plt.legend(
102+
loc="lower left",
103+
fontsize=8,
104+
)
105+
plt.xlabel("Column 1")
106+
plt.ylabel("Column 2")
107+
plt.title("Case 2: MAR missingness mechanism")
108+
plt.grid()
109+
plt.show()
110+
111+
# %%
112+
113+
result = test_mcar.test(df_nan)
114+
print(f"Test p-value: {result:.2%}")
115+
# %%
116+
# The p-value is lower than the classic threshold (5%).
117+
# H0 is then rejected and we can suppose that our missingness mechanism is MAR.
118+
119+
# %%
120+
# Case 3: MAR holes with any mean bias (False negative)
121+
# =====================================================
122+
#
123+
# The specific case is designed to emphasize the Little's test limits. In the case, we generate
124+
# holes when the absolute value of the first feature is high. This missingness mechanism is clearly
125+
# MAR but the means between missing patterns is not statistically different.
126+
127+
df_mask = pd.DataFrame(
128+
{"Column 1": False, "Column 2": df["Column 1"].abs() > q975}, index=df.index
129+
)
130+
131+
df_nan = df.where(~df_mask, np.nan)
132+
133+
has_nan = df_mask.any(axis=1)
134+
df_observed = df.loc[~has_nan]
135+
df_hidden = df.loc[has_nan]
136+
137+
plt.scatter(df_observed["Column 1"], df_observed[["Column 2"]], label="Fully observed values")
138+
plt.scatter(df_hidden[["Column 1"]], df_hidden[["Column 2"]], label="Values with missing C2")
139+
140+
plt.legend(
141+
loc="lower left",
142+
fontsize=8,
143+
)
144+
plt.xlabel("Column 1")
145+
plt.ylabel("Column 2")
146+
plt.title("Case 3: MAR missingness mechanism undetected by the Little's test")
147+
plt.grid()
148+
plt.show()
149+
150+
# %%
151+
152+
result = test_mcar.test(df_nan)
153+
print(f"Test p-value: {result:.2%}")
154+
# %%
155+
# The p-value is higher than the classic threshold (5%).
156+
# H0 is not rejected whereas the missingness mechanism is clearly MAR.
157+
158+
# %%
159+
# Limitations
160+
# -----------
161+
# In this tutoriel, we can see that Little's test fails to detect covariance heterogeneity between
162+
# patterns.
163+
#
164+
# We also note that the Little's test does not handle categorical data or temporally
165+
# correlated data.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Optional, Union
3+
4+
import numpy as np
5+
import pandas as pd
6+
from scipy.stats import chi2
7+
8+
from qolmat.imputations.imputers import ImputerEM
9+
10+
11+
class McarTest(ABC):
12+
"""
13+
Astract class for MCAR tests.
14+
"""
15+
16+
@abstractmethod
17+
def test(self, df: pd.DataFrame) -> float:
18+
pass
19+
20+
21+
class LittleTest(McarTest):
22+
"""
23+
This class implements the Little's test, which is designed to detect the heterogeneity accross
24+
the missing patterns. The null hypothesis is "The missing data mechanism is MCAR". The
25+
shortcoming of this test is that it won't detect the heterogeneity of covariance.
26+
27+
References
28+
----------
29+
Little. "A Test of Missing Completely at Random for Multivariate Data with Missing Values."
30+
Journal of the American Statistical Association, Volume 83, 1988 - Issue 404
31+
32+
Parameters
33+
----------
34+
imputer : Optional[ImputerEM]
35+
Imputer based on the EM algorithm. The 'model' attribute must be equal to 'multinormal'.
36+
If None, the default ImputerEM is taken.
37+
random_state : Union[None, int, np.random.RandomState], optional
38+
Controls the randomness of the fit_transform, by default None
39+
"""
40+
41+
def __init__(
42+
self,
43+
imputer: Optional[ImputerEM] = None,
44+
random_state: Union[None, int, np.random.RandomState] = None,
45+
):
46+
super().__init__()
47+
if imputer and imputer.model != "multinormal":
48+
raise AttributeError(
49+
"The ImputerEM model must be 'multinormal' to use the Little's test"
50+
)
51+
self.imputer = imputer
52+
self.random_state = random_state
53+
54+
def test(self, df: pd.DataFrame) -> float:
55+
"""
56+
Apply the Little's test over a real dataframe.
57+
58+
59+
Parameters
60+
----------
61+
df : pd.DataFrame
62+
The input dataset with missing values.
63+
64+
Returns
65+
-------
66+
float
67+
The p-value of the test.
68+
"""
69+
imputer = self.imputer or ImputerEM(random_state=self.random_state)
70+
imputer = imputer._fit_element(df)
71+
72+
d0 = 0
73+
n_rows, n_cols = df.shape
74+
degree_f = -n_cols
75+
ml_means = imputer.means
76+
ml_cov = n_rows / (n_rows - 1) * imputer.cov
77+
78+
# Iterate over the patterns
79+
80+
df_nan = df.notna()
81+
for tup_pattern, df_nan_pattern in df_nan.groupby(df_nan.columns.tolist()):
82+
n_rows_pattern, _ = df_nan_pattern.shape
83+
ind_pattern = df_nan_pattern.index
84+
df_pattern = df.loc[ind_pattern, list(tup_pattern)]
85+
obs_mean = df_pattern.mean().to_numpy()
86+
87+
diff_means = obs_mean - ml_means[list(tup_pattern)]
88+
inv_sigma_pattern = np.linalg.inv(ml_cov[:, tup_pattern][tup_pattern, :])
89+
90+
d0 += n_rows_pattern * np.dot(np.dot(diff_means, inv_sigma_pattern), diff_means.T)
91+
degree_f += tup_pattern.count(True)
92+
93+
return 1 - float(chi2.cdf(d0, degree_f))

0 commit comments

Comments
 (0)