gitlabds 2.4.0__tar.gz → 2.4.1__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 (30) hide show
  1. {gitlabds-2.4.0 → gitlabds-2.4.1}/PKG-INFO +18 -5
  2. {gitlabds-2.4.0 → gitlabds-2.4.1}/README.md +17 -4
  3. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/feature_reduction.py +185 -89
  4. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds.egg-info/PKG-INFO +18 -5
  5. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds.egg-info/SOURCES.txt +1 -0
  6. gitlabds-2.4.1/tests/test_feature_reduction.py +232 -0
  7. {gitlabds-2.4.0 → gitlabds-2.4.1}/LICENSE +0 -0
  8. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/__init__.py +0 -0
  9. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/baselines.py +0 -0
  10. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/config_generator.py +0 -0
  11. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/dummy.py +0 -0
  12. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/feature_descriptions.py +0 -0
  13. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/insights.py +0 -0
  14. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/memory_optimization.py +0 -0
  15. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/missing.py +0 -0
  16. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/model_evaluator.py +0 -0
  17. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/monitoring_metrics.py +0 -0
  18. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/outliers.py +0 -0
  19. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/record_benchmarking.py +0 -0
  20. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/serving_features.py +0 -0
  21. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/snowflake_connectors.py +0 -0
  22. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/split_data.py +0 -0
  23. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds/trends.py +0 -0
  24. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds.egg-info/dependency_links.txt +0 -0
  25. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds.egg-info/requires.txt +0 -0
  26. {gitlabds-2.4.0 → gitlabds-2.4.1}/gitlabds.egg-info/top_level.txt +0 -0
  27. {gitlabds-2.4.0 → gitlabds-2.4.1}/pyproject.toml +0 -0
  28. {gitlabds-2.4.0 → gitlabds-2.4.1}/setup.cfg +0 -0
  29. {gitlabds-2.4.0 → gitlabds-2.4.1}/setup.py +0 -0
  30. {gitlabds-2.4.0 → gitlabds-2.4.1}/tests/test_snowflake_connectors.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlabds
3
- Version: 2.4.0
3
+ Version: 2.4.1
4
4
  Summary: GitLab Data Science Tools
5
5
  Author-email: Kevin Dietz <kdietz@gitlab.com>
6
6
  License-Expression: MIT
@@ -376,9 +376,9 @@ test_df_transformed = gitlabds.apply_dummy(
376
376
  <details><summary> Remove Low Variation </summary>
377
377
 
378
378
  #### Description
379
- Remove columns from a dataset that do not meet the variation threshold. That is, columns will be dropped that contain a high percentage of one value.
379
+ Remove columns from a dataset that do not meet the variation threshold. That is, columns will be dropped that contain a high percentage of one value. Columns that are entirely null are always dropped.
380
380
 
381
- `gitlabds.remove_low_variation(df=None, dv=None, columns='all', threshold=.98, verbose=True):`
381
+ `gitlabds.remove_low_variation(df=None, dv=None, columns='all', threshold=.98, verbose=True, dropna=False):`
382
382
 
383
383
  #### Parameters:
384
384
  - _**df**_ : your pandas dataframe
@@ -386,6 +386,7 @@ Remove columns from a dataset that do not meet the variation threshold. That is,
386
386
  - **_columns_** : Will examine at all columns by default. To limit to just a subset of columns, pass a list of column names.
387
387
  - **_threshold_**: The maximum percentage one value in a column can represent. columns that exceed this threshold will be dropped. For example, the default value of `0.98` will drop any column where one value is present in more than 98% of rows.
388
388
  - **_verbose_** : Set to `True` to print outputs of columns being dropped. Set to `False` to suppress.
389
+ - **_dropna_** : Set to `False` (default) to count missing values as their own value. Set to `True` to measure variation over non-null values only, so a column that is one value plus NaN is dropped. Use `True` when missing values are left in place for a model that handles them natively (e.g. XGBoost, LightGBM).
389
390
 
390
391
  #### Returns
391
392
  - DataFrame with low variation columns dropped.
@@ -399,6 +400,10 @@ new_df = gitlabds.remove_low_variation(df=my_df, dv='my_outcome', columns='all',
399
400
  # Drop any of the selected columns where one value is present in more than 99% of rows.
400
401
  new_df = gitlabds.remove_low_variation(df=my_df, dv=None, columns=['colA', 'colB', 'colC'], threshold=.99)
401
402
  ```
403
+ ```python
404
+ # Ignore missing values: drop columns where one value makes up more than 98% of the non-null rows.
405
+ new_df = gitlabds.remove_low_variation(df=my_df, dv='my_outcome', dropna=True)
406
+ ```
402
407
  </details>
403
408
 
404
409
  <details><summary> Correlation Reduction </summary>
@@ -406,7 +411,7 @@ new_df = gitlabds.remove_low_variation(df=my_df, dv=None, columns=['colA', 'colB
406
411
  #### Description
407
412
  Reduce the number of columns on a dataframe by dropping columns that are highly correlated with other columns. Note: only one of the two highly correlated columns will be dropped.
408
413
 
409
- `gitlabds.correlation_reduction(df=None, dv=None, threshold=0.9, method="pearson", verbose=True):`
414
+ `gitlabds.correlation_reduction(df=None, dv=None, threshold=0.9, method="pearson", verbose=True, dropna=True, min_periods=1):`
410
415
 
411
416
  #### Parameters:
412
417
  - _**df**_ : your pandas dataframe
@@ -414,6 +419,8 @@ Reduce the number of columns on a dataframe by dropping columns that are highly
414
419
  - **_threshold_**: The threshold above which columns will be dropped. If two variables exceed this threshold, one will be dropped from the dataframe. For example, the default value of `0.90` will identify columns that have correlations greater than 90% to each other and drop one of those columns.
415
420
  - **_method_**: The correlation method to use. Options are "pearson" (linear relationships), "spearman" (monotonic relationships), or "mutual_info" (any statistical dependency).
416
421
  - **_verbose_** : Set to `True` to print outputs of columns being dropped. Set to `False` to suppress.
422
+ - **_dropna_** : Set to `True` (default) to compare each pair of columns (and each column with the outcome) using only rows where both are non-null. Pearson and Spearman always work this way; for "mutual_info", `False` raises an error if any missing values are present.
423
+ - **_min_periods_** : Minimum number of rows where both columns of a pair are non-null for the pair to be evaluated. Pairs below it are never treated as correlated. Guards against spurious high correlations between sparsely populated columns. "mutual_info" always requires at least 4 such rows.
417
424
 
418
425
  #### Returns
419
426
  - DataFrame with redundant correlated columns dropped.
@@ -434,7 +441,7 @@ new_df = gitlabds.correlation_reduction(df=my_df, dv=None, threshold=0.90, metho
434
441
  #### Description
435
442
  Remove columns that are highly correlated with the outcome (target) column.
436
443
 
437
- `gitlabds.remove_outcome_proxies(df, dv, threshold=.8, method="pearson", verbose=True):`
444
+ `gitlabds.remove_outcome_proxies(df, dv, threshold=.8, method="pearson", verbose=True, dropna=True, min_periods=1):`
438
445
 
439
446
  #### Parameters:
440
447
  - _**df**_ : your pandas dataframe
@@ -442,6 +449,8 @@ Remove columns that are highly correlated with the outcome (target) column.
442
449
  - _**threshold**_ : The correlation value to the outcome above which columns will be dropped. For example, the default value of `0.80` will identify and drop columns that have correlations greater than 80% to the outcome.
443
450
  - **_method_**: The correlation method to use. Options are "pearson" (linear relationships), "spearman" (monotonic relationships), or "mutual_info" (any statistical dependency).
444
451
  - **_verbose_** : Set to `True` to print outputs of columns being dropped. Set to `False` to suppress.
452
+ - **_dropna_** : Set to `True` (default) to compare each column to the outcome using only rows where both are non-null. Pearson and Spearman always work this way; for "mutual_info", `False` raises an error if any missing values are present.
453
+ - **_min_periods_** : Minimum number of rows where both the column and the outcome are non-null for the column to be evaluated. Columns below it are never dropped. Guards against spurious high correlations from sparsely populated columns. "mutual_info" always requires at least 4 such rows.
445
454
 
446
455
  #### Returns
447
456
  - DataFrame with outcome proxy columns dropped.
@@ -455,6 +464,10 @@ new_df = gitlabds.remove_outcome_proxies(df=my_df, dv='my_outcome', threshold=.7
455
464
  # Drop columns with correlations to the outcome greater than 80% using Spearman correlation
456
465
  new_df = gitlabds.remove_outcome_proxies(df=my_df, dv='my_outcome', threshold=.8, method="spearman")
457
466
  ```
467
+ ```python
468
+ # Data with missing values: only evaluate columns with at least 100 non-null rows alongside the outcome
469
+ new_df = gitlabds.remove_outcome_proxies(df=my_df, dv='my_outcome', method="spearman", min_periods=100)
470
+ ```
458
471
  </details>
459
472
 
460
473
  <details><summary> Drop Categorical </summary>
@@ -340,9 +340,9 @@ test_df_transformed = gitlabds.apply_dummy(
340
340
  <details><summary> Remove Low Variation </summary>
341
341
 
342
342
  #### Description
343
- Remove columns from a dataset that do not meet the variation threshold. That is, columns will be dropped that contain a high percentage of one value.
343
+ Remove columns from a dataset that do not meet the variation threshold. That is, columns will be dropped that contain a high percentage of one value. Columns that are entirely null are always dropped.
344
344
 
345
- `gitlabds.remove_low_variation(df=None, dv=None, columns='all', threshold=.98, verbose=True):`
345
+ `gitlabds.remove_low_variation(df=None, dv=None, columns='all', threshold=.98, verbose=True, dropna=False):`
346
346
 
347
347
  #### Parameters:
348
348
  - _**df**_ : your pandas dataframe
@@ -350,6 +350,7 @@ Remove columns from a dataset that do not meet the variation threshold. That is,
350
350
  - **_columns_** : Will examine at all columns by default. To limit to just a subset of columns, pass a list of column names.
351
351
  - **_threshold_**: The maximum percentage one value in a column can represent. columns that exceed this threshold will be dropped. For example, the default value of `0.98` will drop any column where one value is present in more than 98% of rows.
352
352
  - **_verbose_** : Set to `True` to print outputs of columns being dropped. Set to `False` to suppress.
353
+ - **_dropna_** : Set to `False` (default) to count missing values as their own value. Set to `True` to measure variation over non-null values only, so a column that is one value plus NaN is dropped. Use `True` when missing values are left in place for a model that handles them natively (e.g. XGBoost, LightGBM).
353
354
 
354
355
  #### Returns
355
356
  - DataFrame with low variation columns dropped.
@@ -363,6 +364,10 @@ new_df = gitlabds.remove_low_variation(df=my_df, dv='my_outcome', columns='all',
363
364
  # Drop any of the selected columns where one value is present in more than 99% of rows.
364
365
  new_df = gitlabds.remove_low_variation(df=my_df, dv=None, columns=['colA', 'colB', 'colC'], threshold=.99)
365
366
  ```
367
+ ```python
368
+ # Ignore missing values: drop columns where one value makes up more than 98% of the non-null rows.
369
+ new_df = gitlabds.remove_low_variation(df=my_df, dv='my_outcome', dropna=True)
370
+ ```
366
371
  </details>
367
372
 
368
373
  <details><summary> Correlation Reduction </summary>
@@ -370,7 +375,7 @@ new_df = gitlabds.remove_low_variation(df=my_df, dv=None, columns=['colA', 'colB
370
375
  #### Description
371
376
  Reduce the number of columns on a dataframe by dropping columns that are highly correlated with other columns. Note: only one of the two highly correlated columns will be dropped.
372
377
 
373
- `gitlabds.correlation_reduction(df=None, dv=None, threshold=0.9, method="pearson", verbose=True):`
378
+ `gitlabds.correlation_reduction(df=None, dv=None, threshold=0.9, method="pearson", verbose=True, dropna=True, min_periods=1):`
374
379
 
375
380
  #### Parameters:
376
381
  - _**df**_ : your pandas dataframe
@@ -378,6 +383,8 @@ Reduce the number of columns on a dataframe by dropping columns that are highly
378
383
  - **_threshold_**: The threshold above which columns will be dropped. If two variables exceed this threshold, one will be dropped from the dataframe. For example, the default value of `0.90` will identify columns that have correlations greater than 90% to each other and drop one of those columns.
379
384
  - **_method_**: The correlation method to use. Options are "pearson" (linear relationships), "spearman" (monotonic relationships), or "mutual_info" (any statistical dependency).
380
385
  - **_verbose_** : Set to `True` to print outputs of columns being dropped. Set to `False` to suppress.
386
+ - **_dropna_** : Set to `True` (default) to compare each pair of columns (and each column with the outcome) using only rows where both are non-null. Pearson and Spearman always work this way; for "mutual_info", `False` raises an error if any missing values are present.
387
+ - **_min_periods_** : Minimum number of rows where both columns of a pair are non-null for the pair to be evaluated. Pairs below it are never treated as correlated. Guards against spurious high correlations between sparsely populated columns. "mutual_info" always requires at least 4 such rows.
381
388
 
382
389
  #### Returns
383
390
  - DataFrame with redundant correlated columns dropped.
@@ -398,7 +405,7 @@ new_df = gitlabds.correlation_reduction(df=my_df, dv=None, threshold=0.90, metho
398
405
  #### Description
399
406
  Remove columns that are highly correlated with the outcome (target) column.
400
407
 
401
- `gitlabds.remove_outcome_proxies(df, dv, threshold=.8, method="pearson", verbose=True):`
408
+ `gitlabds.remove_outcome_proxies(df, dv, threshold=.8, method="pearson", verbose=True, dropna=True, min_periods=1):`
402
409
 
403
410
  #### Parameters:
404
411
  - _**df**_ : your pandas dataframe
@@ -406,6 +413,8 @@ Remove columns that are highly correlated with the outcome (target) column.
406
413
  - _**threshold**_ : The correlation value to the outcome above which columns will be dropped. For example, the default value of `0.80` will identify and drop columns that have correlations greater than 80% to the outcome.
407
414
  - **_method_**: The correlation method to use. Options are "pearson" (linear relationships), "spearman" (monotonic relationships), or "mutual_info" (any statistical dependency).
408
415
  - **_verbose_** : Set to `True` to print outputs of columns being dropped. Set to `False` to suppress.
416
+ - **_dropna_** : Set to `True` (default) to compare each column to the outcome using only rows where both are non-null. Pearson and Spearman always work this way; for "mutual_info", `False` raises an error if any missing values are present.
417
+ - **_min_periods_** : Minimum number of rows where both the column and the outcome are non-null for the column to be evaluated. Columns below it are never dropped. Guards against spurious high correlations from sparsely populated columns. "mutual_info" always requires at least 4 such rows.
409
418
 
410
419
  #### Returns
411
420
  - DataFrame with outcome proxy columns dropped.
@@ -419,6 +428,10 @@ new_df = gitlabds.remove_outcome_proxies(df=my_df, dv='my_outcome', threshold=.7
419
428
  # Drop columns with correlations to the outcome greater than 80% using Spearman correlation
420
429
  new_df = gitlabds.remove_outcome_proxies(df=my_df, dv='my_outcome', threshold=.8, method="spearman")
421
430
  ```
431
+ ```python
432
+ # Data with missing values: only evaluate columns with at least 100 non-null rows alongside the outcome
433
+ new_df = gitlabds.remove_outcome_proxies(df=my_df, dv='my_outcome', method="spearman", min_periods=100)
434
+ ```
422
435
  </details>
423
436
 
424
437
  <details><summary> Drop Categorical </summary>
@@ -57,13 +57,15 @@ def remove_low_variation(
57
57
  dv: Optional[str] = None,
58
58
  columns: Union[str, List[str]] = "all",
59
59
  threshold: float = 0.98,
60
- verbose: bool = True
60
+ verbose: bool = True,
61
+ dropna: bool = False
61
62
  ) -> pd.DataFrame:
62
63
  """
63
64
  Remove columns from a dataset that do not meet the variation threshold.
64
65
 
65
66
  Columns will be dropped if they contain a high percentage of one value, as they
66
- provide minimal information for modeling.
67
+ provide minimal information for modeling. Columns that are entirely null are
68
+ always dropped.
67
69
 
68
70
  Parameters
69
71
  ----------
@@ -81,6 +83,13 @@ def remove_low_variation(
81
83
  will drop any column where one value is present in more than 98% of rows.
82
84
  verbose : bool, default=True
83
85
  Set to True to print outputs of columns being dropped. Set to False to suppress.
86
+ dropna : bool, default=False
87
+ If False, missing values count as their own value, so a column that is one value
88
+ plus NaN is kept as long as neither exceeds the threshold. If True, the share of
89
+ the most common value is computed over non-null rows only, so a column with a
90
+ single distinct non-null value is dropped regardless of how many NaNs it has.
91
+ Use True when missing values will be left in place for a model that handles them
92
+ natively (e.g. XGBoost, LightGBM).
84
93
 
85
94
  Returns
86
95
  -------
@@ -90,7 +99,7 @@ def remove_low_variation(
90
99
  Examples
91
100
  --------
92
101
  >>> new_df = remove_low_variation(df=my_df, dv='target', threshold=0.95)
93
- >>> print(f"Removed columns due to low variation")
102
+ >>> new_df = remove_low_variation(df=my_df, dv='target', dropna=True)
94
103
  """
95
104
  # Enhanced parameter validation
96
105
  if not isinstance(df, pd.DataFrame):
@@ -105,6 +114,9 @@ def remove_low_variation(
105
114
  if dv is not None and dv not in df.columns:
106
115
  warnings.warn(f"Specified DV '{dv}' not found in dataframe")
107
116
 
117
+ if not isinstance(dropna, bool):
118
+ raise TypeError(f"dropna must be a bool, got {type(dropna)}")
119
+
108
120
  # Create a deep copy of the dataframe
109
121
  work_df = df.copy(deep=True)
110
122
 
@@ -115,7 +127,7 @@ def remove_low_variation(
115
127
  print("\nExamining all columns as candidates for removal")
116
128
  else:
117
129
  if isinstance(columns, list):
118
- var_list = columns
130
+ var_list = list(columns)
119
131
  if verbose:
120
132
  print(f"\nExamining the following columns as candidates for removal: {var_list}")
121
133
  else:
@@ -139,12 +151,15 @@ def remove_low_variation(
139
151
  # Process all columns
140
152
  for v in var_list:
141
153
  try:
142
- # Skip non-finite columns
154
+ # Drop columns with no non-null values
143
155
  if work_df[v].isna().all():
156
+ removal_list.append(v)
157
+ if verbose:
158
+ print(f"Column '{v}' contains only null values")
144
159
  continue
145
160
 
146
161
  # Get value counts for the column
147
- counts = work_df[v].value_counts(normalize=True, dropna=False)
162
+ counts = work_df[v].value_counts(normalize=True, dropna=dropna)
148
163
  max_freq = counts.iloc[0]
149
164
  max_value = counts.index[0]
150
165
 
@@ -153,7 +168,8 @@ def remove_low_variation(
153
168
  removal_list.append(v)
154
169
 
155
170
  if verbose:
156
- print(f"In column '{v}', the value {max_value} accounts for {max_freq:.2%} of the values (threshold: {threshold:.2%})")
171
+ basis = "non-null values" if dropna else "values"
172
+ print(f"In column '{v}', the value {max_value} accounts for {max_freq:.2%} of the {basis} (threshold: {threshold:.2%})")
157
173
  except Exception as e:
158
174
  warnings.warn(f"Error processing column '{v}': {str(e)}")
159
175
 
@@ -166,12 +182,47 @@ def remove_low_variation(
166
182
  return work_df
167
183
 
168
184
 
185
+ def _mutual_info_to_target(
186
+ X: pd.DataFrame,
187
+ y: pd.Series,
188
+ min_periods: int = 1
189
+ ) -> pd.Series:
190
+ """
191
+ Mutual information of each column of X with y, on rows where both are non-null.
192
+
193
+ Columns with no nulls on y's non-null rows are scored in one call; the rest are scored
194
+ one at a time on their own complete rows. Columns with fewer than max(min_periods, 4)
195
+ complete rows are returned as NaN.
196
+ """
197
+ if isinstance(y.dtype, pd.CategoricalDtype) or y.nunique() < 10:
198
+ mi_func = mutual_info_classif
199
+ else:
200
+ mi_func = mutual_info_regression
201
+
202
+ y_rows = y.notna()
203
+ pair_counts = X.notna().mul(y_rows, axis=0).sum()
204
+ eligible = pair_counts[pair_counts >= max(min_periods, 4)]
205
+ complete_cols = eligible.index[eligible == y_rows.sum()].tolist()
206
+ partial_cols = eligible.index[eligible < y_rows.sum()].tolist()
207
+
208
+ mi_values = pd.Series(np.nan, index=X.columns, dtype=float)
209
+ if complete_cols:
210
+ mi_values[complete_cols] = mi_func(X.loc[y_rows, complete_cols], y[y_rows])
211
+ for col in partial_cols:
212
+ rows = y_rows & X[col].notna()
213
+ mi_values[col] = mi_func(X.loc[rows, [col]], y[rows])[0]
214
+
215
+ return mi_values
216
+
217
+
169
218
  def remove_outcome_proxies(
170
219
  df: pd.DataFrame,
171
220
  dv: str,
172
221
  threshold: float = 0.8,
173
222
  method: str = "pearson",
174
- verbose: bool = True
223
+ verbose: bool = True,
224
+ dropna: bool = True,
225
+ min_periods: int = 1
175
226
  ) -> pd.DataFrame:
176
227
  """
177
228
  Remove columns that are highly correlated with the outcome (target) column.
@@ -196,6 +247,17 @@ def remove_outcome_proxies(
196
247
  - "mutual_info": Mutual information (any statistical dependency)
197
248
  verbose : bool, default=True
198
249
  Set to True to print outputs of columns being dropped. Set to False to suppress.
250
+ dropna : bool, default=True
251
+ If True, each feature is compared to `dv` using only the rows where both are
252
+ non-null. "pearson" and "spearman" always work this way (pandas pairwise-complete
253
+ correlation), so this flag only changes "mutual_info": with False, any missing
254
+ value in the numeric features or `dv` raises a ValueError, because scikit-learn's
255
+ mutual information estimators do not accept NaN.
256
+ min_periods : int, default=1
257
+ Minimum number of rows where both the feature and `dv` are non-null for the
258
+ feature to be evaluated. Features below it are never flagged as proxies. Guards
259
+ against spurious high correlations from sparsely populated columns. "mutual_info"
260
+ always requires at least 4 such rows.
199
261
 
200
262
  Returns
201
263
  -------
@@ -204,8 +266,8 @@ def remove_outcome_proxies(
204
266
 
205
267
  Examples
206
268
  --------
207
- >>> new_df = find_outcome_proxies(df=my_df, dv='target', threshold=0.7, method="spearman")
208
- >>> print(f"Removed columns as outcome proxies")
269
+ >>> new_df = remove_outcome_proxies(df=my_df, dv='target', threshold=0.7, method="spearman")
270
+ >>> new_df = remove_outcome_proxies(df=my_df, dv='target', method="mutual_info", min_periods=100)
209
271
  """
210
272
  # Parameter validation
211
273
  if not isinstance(df, pd.DataFrame):
@@ -230,6 +292,15 @@ def remove_outcome_proxies(
230
292
  if method not in valid_methods:
231
293
  raise ValueError(f"method must be one of {valid_methods}, got {method}")
232
294
 
295
+ if not isinstance(dropna, bool):
296
+ raise TypeError(f"dropna must be a bool, got {type(dropna)}")
297
+
298
+ if isinstance(min_periods, bool) or not isinstance(min_periods, int):
299
+ raise TypeError(f"min_periods must be an int, got {type(min_periods)}")
300
+
301
+ if min_periods < 1:
302
+ raise ValueError(f"min_periods must be at least 1, got {min_periods}")
303
+
233
304
  # Create a deep copy of the dataframe
234
305
  work_df = df.copy(deep=True)
235
306
 
@@ -241,35 +312,35 @@ def remove_outcome_proxies(
241
312
  # Remove the target variable from the list of features
242
313
  feature_cols = [col for col in numeric_cols if col != dv]
243
314
 
315
+ y = work_df[dv]
316
+
244
317
  # Calculate correlations based on the specified method
245
318
  if method in ["pearson", "spearman"]:
246
- # Calculate correlations using pandas
247
- correlations = work_df[numeric_cols].corr(method=method)[dv].drop(dv)
319
+ # Per-feature correlation with the dv (what DataFrame.corrwith does, plus min_periods);
320
+ # avoids computing the full feature-by-feature matrix
321
+ correlations = pd.Series(
322
+ {col: work_df[col].corr(y, method=method, min_periods=min_periods) for col in feature_cols},
323
+ dtype=float
324
+ )
248
325
  corr_values = {col: abs(corr) for col, corr in correlations.items() if abs(corr) > threshold}
249
326
 
250
327
  elif method == "mutual_info":
251
- # For mutual information, we need to handle the target type differently
252
- # Create X and y for mutual information calculation
253
328
  X = work_df[feature_cols]
254
- y = work_df[dv]
255
329
 
256
- # Determine if this is a classification or regression problem
257
- if pd.api.types.is_categorical_dtype(y) or y.nunique() < 10:
258
- # Classification problem
259
- mi_values = mutual_info_classif(X, y)
260
- else:
261
- # Regression problem
262
- mi_values = mutual_info_regression(X, y)
330
+ if not dropna and (X.isna().any().any() or y.isna().any()):
331
+ raise ValueError(
332
+ "mutual_info cannot be computed on missing values; set dropna=True to use "
333
+ "the rows where each feature and the dv are both non-null"
334
+ )
335
+
336
+ mi_values = _mutual_info_to_target(X, y, min_periods)
263
337
 
264
338
  # Normalize MI values to 0-1 range for comparison with threshold
265
- if len(mi_values) > 0:
266
- mi_max = max(mi_values) if max(mi_values) > 0 else 1
267
- mi_normalized = mi_values / mi_max
268
-
269
- # Create dictionary of feature: mi_value
270
- corr_values = {col: mi for col, mi in zip(feature_cols, mi_normalized) if mi > threshold}
271
- else:
272
- corr_values = {}
339
+ mi_max = mi_values.max()
340
+ if not mi_max > 0:
341
+ mi_max = 1
342
+ mi_normalized = mi_values / mi_max
343
+ corr_values = {col: mi for col, mi in mi_normalized.items() if mi > threshold}
273
344
 
274
345
  # Create the list of columns to drop
275
346
  dropped_columns = list(corr_values.keys())
@@ -295,7 +366,9 @@ def correlation_reduction(
295
366
  dv: Optional[str] = None,
296
367
  threshold: float = 0.9,
297
368
  method: str = "pearson",
298
- verbose: bool = True
369
+ verbose: bool = True,
370
+ dropna: bool = True,
371
+ min_periods: int = 1
299
372
  ) -> pd.DataFrame:
300
373
  """
301
374
  Reduce the number of columns by dropping features that are highly correlated with each other.
@@ -311,6 +384,7 @@ def correlation_reduction(
311
384
  dv : str, optional
312
385
  The column name of your outcome. If provided, when choosing between
313
386
  correlated features, the one with higher correlation to the target will be kept.
387
+ A feature whose correlation to the target cannot be computed counts as 0.
314
388
  threshold : float, default=0.9
315
389
  The correlation threshold above which columns will be considered redundant.
316
390
  For example, the default value of 0.9 will identify columns that have
@@ -319,9 +393,21 @@ def correlation_reduction(
319
393
  The correlation method to use. Options are:
320
394
  - "pearson": Standard correlation coefficient (linear relationships)
321
395
  - "spearman": Spearman rank correlation (monotonic relationships)
322
- - "mutual_info": Mutual information (any statistical dependency)
396
+ - "mutual_info": Mutual information (any statistical dependency). Scores every
397
+ feature pair, so it is slow on wide data.
323
398
  verbose : bool, default=True
324
399
  Set to True to print outputs of columns being dropped. Set to False to suppress.
400
+ dropna : bool, default=True
401
+ If True, each pair (feature-feature, and feature-dv) is compared using only the
402
+ rows where both are non-null. "pearson" and "spearman" always work this way
403
+ (pandas pairwise-complete correlation), so this flag only changes "mutual_info":
404
+ with False, any missing value in the numeric features or `dv` raises a ValueError,
405
+ because scikit-learn's mutual information estimators do not accept NaN.
406
+ min_periods : int, default=1
407
+ Minimum number of rows where both columns of a pair are non-null for the pair to
408
+ be evaluated. Pairs below it are never treated as correlated. Guards against
409
+ spurious high correlations between sparsely populated columns. "mutual_info"
410
+ always requires at least 4 such rows.
325
411
 
326
412
  Returns
327
413
  -------
@@ -330,8 +416,8 @@ def correlation_reduction(
330
416
 
331
417
  Examples
332
418
  --------
333
- >>> new_df = reduce_correlation(df=my_df, dv='target', threshold=0.85, method="spearman")
334
- >>> print(f"Removed correlated columns")
419
+ >>> new_df = correlation_reduction(df=my_df, dv='target', threshold=0.85, method="spearman")
420
+ >>> new_df = correlation_reduction(df=my_df, dv='target', min_periods=100)
335
421
  """
336
422
  # Parameter validation
337
423
  if not isinstance(df, pd.DataFrame):
@@ -354,6 +440,15 @@ def correlation_reduction(
354
440
  if method not in valid_methods:
355
441
  raise ValueError(f"method must be one of {valid_methods}, got {method}")
356
442
 
443
+ if not isinstance(dropna, bool):
444
+ raise TypeError(f"dropna must be a bool, got {type(dropna)}")
445
+
446
+ if isinstance(min_periods, bool) or not isinstance(min_periods, int):
447
+ raise TypeError(f"min_periods must be an int, got {type(min_periods)}")
448
+
449
+ if min_periods < 1:
450
+ raise ValueError(f"min_periods must be at least 1, got {min_periods}")
451
+
357
452
  # Create a deep copy of the dataframe
358
453
  work_df = df.copy(deep=True)
359
454
 
@@ -364,46 +459,47 @@ def correlation_reduction(
364
459
  # Remove the target variable from the list of features to consider for removal
365
460
  feature_cols.remove(dv)
366
461
 
367
- # Ensure we have the target in numeric_cols for correlation calculations if it's numeric
368
- numeric_cols = feature_cols.copy()
369
- if dv is not None and dv in work_df.columns and pd.api.types.is_numeric_dtype(work_df[dv]):
370
- numeric_cols.append(dv)
371
-
372
462
  # Check if we have enough numeric columns to proceed
373
463
  if len(feature_cols) < 2:
374
464
  if verbose:
375
465
  print("Not enough numeric columns for correlation analysis")
376
466
  return work_df
377
467
 
468
+ if method == "mutual_info" and not dropna:
469
+ check_cols = feature_cols + ([dv] if dv is not None else [])
470
+ if work_df[check_cols].isna().any().any():
471
+ raise ValueError(
472
+ "mutual_info cannot be computed on missing values; set dropna=True to use "
473
+ "the rows where both columns of each pair are non-null"
474
+ )
475
+
378
476
  # Calculate correlations based on the chosen method
379
477
  if method in ["pearson", "spearman"]:
380
- # Standard correlation methods
381
- correlation_matrix = work_df[numeric_cols].corr(method=method).abs()
382
-
383
- # Create a list of feature pairs and their correlation
384
- corr_pairs = []
385
- for i in range(len(feature_cols)):
386
- for j in range(i+1, len(feature_cols)):
387
- # Get the column names
388
- col1 = feature_cols[i]
389
- col2 = feature_cols[j]
390
-
391
- # Find their positions in the correlation matrix
392
- idx1 = numeric_cols.index(col1)
393
- idx2 = numeric_cols.index(col2)
394
-
395
- # Check if correlation exceeds threshold
396
- if correlation_matrix.iloc[idx1, idx2] > threshold:
397
- corr_pairs.append({
398
- "feature1": col1,
399
- "feature2": col2,
400
- "correlation": correlation_matrix.iloc[idx1, idx2]
401
- })
478
+ # Standard correlation methods; pairs above the threshold from the upper triangle
479
+ correlation_matrix = work_df[feature_cols].corr(method=method, min_periods=min_periods).abs().to_numpy()
480
+ rows, cols = np.triu_indices(len(feature_cols), k=1)
481
+ pair_corr = correlation_matrix[rows, cols]
482
+ above = pair_corr > threshold
483
+ corr_pairs = [
484
+ {"feature1": feature_cols[i], "feature2": feature_cols[j], "correlation": corr}
485
+ for i, j, corr in zip(rows[above], cols[above], pair_corr[above])
486
+ ]
402
487
 
403
488
  elif method == "mutual_info":
404
489
  # For mutual information between features
405
490
  corr_pairs = []
406
491
  X = work_df[feature_cols]
492
+ non_null = X.notna()
493
+ complete = non_null.all()
494
+ n_unique = X.nunique()
495
+ min_rows = max(min_periods, 4)
496
+
497
+ # MI of each feature with itself, used for normalization; computed once for
498
+ # features without nulls, per pair otherwise
499
+ full_entropy = {}
500
+
501
+ def self_mi(values: pd.Series) -> float:
502
+ return mutual_info_regression(values.to_frame(), values.to_numpy())[0]
407
503
 
408
504
  # Calculate pairwise mutual information
409
505
  for i in range(len(feature_cols)):
@@ -412,23 +508,29 @@ def correlation_reduction(
412
508
  feature2 = feature_cols[j]
413
509
 
414
510
  # Skip if either column has all identical values
415
- if X[feature1].nunique() <= 1 or X[feature2].nunique() <= 1:
511
+ if n_unique[feature1] <= 1 or n_unique[feature2] <= 1:
416
512
  continue
417
513
 
418
- # Calculate mutual information between the two features
419
- mi = mutual_info_regression(
420
- X[[feature1]], X[feature2].values.reshape(-1, 1)
421
- )[0]
514
+ if complete[feature1] and complete[feature2]:
515
+ if len(X) < min_rows:
516
+ continue
517
+ x1, x2 = X[feature1], X[feature2]
518
+ if feature1 not in full_entropy:
519
+ full_entropy[feature1] = self_mi(x1)
520
+ if feature2 not in full_entropy:
521
+ full_entropy[feature2] = self_mi(x2)
522
+ entropy1, entropy2 = full_entropy[feature1], full_entropy[feature2]
523
+ else:
524
+ pair_rows = non_null[feature1] & non_null[feature2]
525
+ if pair_rows.sum() < min_rows:
526
+ continue
527
+ x1, x2 = X.loc[pair_rows, feature1], X.loc[pair_rows, feature2]
528
+ entropy1, entropy2 = self_mi(x1), self_mi(x2)
422
529
 
423
- # Normalize by entropy for a 0-1 scale
424
- entropy1 = mutual_info_regression(
425
- X[[feature1]], X[feature1].values.reshape(-1, 1)
426
- )[0]
427
- entropy2 = mutual_info_regression(
428
- X[[feature2]], X[feature2].values.reshape(-1, 1)
429
- )[0]
530
+ # Calculate mutual information between the two features
531
+ mi = mutual_info_regression(x1.to_frame(), x2.to_numpy())[0]
430
532
 
431
- # Avoid division by zero
533
+ # Normalize by entropy for a 0-1 scale; avoid division by zero
432
534
  if entropy1 > 0 and entropy2 > 0:
433
535
  normalized_mi = mi / min(entropy1, entropy2)
434
536
 
@@ -448,21 +550,17 @@ def correlation_reduction(
448
550
 
449
551
  if dv is not None and dv in work_df.columns:
450
552
  # If we have a target variable, calculate correlations to the target
553
+ y = work_df[dv]
451
554
  if method in ["pearson", "spearman"]:
452
- target_corr = work_df[numeric_cols].corr(method=method)[dv].abs()
453
- target_corr = target_corr.drop(dv) if dv in target_corr.index else target_corr
555
+ target_corr = pd.Series(
556
+ {col: work_df[col].corr(y, method=method, min_periods=min_periods) for col in feature_cols},
557
+ dtype=float
558
+ ).abs()
454
559
  else: # mutual_info
455
- X = work_df[feature_cols] # Only use feature columns, not target
456
- y = work_df[dv]
457
-
458
- # Determine if classification or regression
459
- if pd.api.types.is_categorical_dtype(y) or y.nunique() < 10:
460
- mi_values = mutual_info_classif(X, y)
461
- else:
462
- mi_values = mutual_info_regression(X, y)
463
-
464
- # Create a Series for target correlations
465
- target_corr = pd.Series(mi_values, index=feature_cols)
560
+ target_corr = _mutual_info_to_target(work_df[feature_cols], y, min_periods)
561
+
562
+ # A feature whose target correlation cannot be computed ranks below any real one
563
+ target_corr = target_corr.fillna(0)
466
564
 
467
565
  # Process each correlated pair
468
566
  for pair in corr_pairs:
@@ -524,5 +622,3 @@ def correlation_reduction(
524
622
  work_df = work_df.drop(columns=dropped_columns)
525
623
 
526
624
  return work_df
527
-
528
- return work_df
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlabds
3
- Version: 2.4.0
3
+ Version: 2.4.1
4
4
  Summary: GitLab Data Science Tools
5
5
  Author-email: Kevin Dietz <kdietz@gitlab.com>
6
6
  License-Expression: MIT
@@ -376,9 +376,9 @@ test_df_transformed = gitlabds.apply_dummy(
376
376
  <details><summary> Remove Low Variation </summary>
377
377
 
378
378
  #### Description
379
- Remove columns from a dataset that do not meet the variation threshold. That is, columns will be dropped that contain a high percentage of one value.
379
+ Remove columns from a dataset that do not meet the variation threshold. That is, columns will be dropped that contain a high percentage of one value. Columns that are entirely null are always dropped.
380
380
 
381
- `gitlabds.remove_low_variation(df=None, dv=None, columns='all', threshold=.98, verbose=True):`
381
+ `gitlabds.remove_low_variation(df=None, dv=None, columns='all', threshold=.98, verbose=True, dropna=False):`
382
382
 
383
383
  #### Parameters:
384
384
  - _**df**_ : your pandas dataframe
@@ -386,6 +386,7 @@ Remove columns from a dataset that do not meet the variation threshold. That is,
386
386
  - **_columns_** : Will examine at all columns by default. To limit to just a subset of columns, pass a list of column names.
387
387
  - **_threshold_**: The maximum percentage one value in a column can represent. columns that exceed this threshold will be dropped. For example, the default value of `0.98` will drop any column where one value is present in more than 98% of rows.
388
388
  - **_verbose_** : Set to `True` to print outputs of columns being dropped. Set to `False` to suppress.
389
+ - **_dropna_** : Set to `False` (default) to count missing values as their own value. Set to `True` to measure variation over non-null values only, so a column that is one value plus NaN is dropped. Use `True` when missing values are left in place for a model that handles them natively (e.g. XGBoost, LightGBM).
389
390
 
390
391
  #### Returns
391
392
  - DataFrame with low variation columns dropped.
@@ -399,6 +400,10 @@ new_df = gitlabds.remove_low_variation(df=my_df, dv='my_outcome', columns='all',
399
400
  # Drop any of the selected columns where one value is present in more than 99% of rows.
400
401
  new_df = gitlabds.remove_low_variation(df=my_df, dv=None, columns=['colA', 'colB', 'colC'], threshold=.99)
401
402
  ```
403
+ ```python
404
+ # Ignore missing values: drop columns where one value makes up more than 98% of the non-null rows.
405
+ new_df = gitlabds.remove_low_variation(df=my_df, dv='my_outcome', dropna=True)
406
+ ```
402
407
  </details>
403
408
 
404
409
  <details><summary> Correlation Reduction </summary>
@@ -406,7 +411,7 @@ new_df = gitlabds.remove_low_variation(df=my_df, dv=None, columns=['colA', 'colB
406
411
  #### Description
407
412
  Reduce the number of columns on a dataframe by dropping columns that are highly correlated with other columns. Note: only one of the two highly correlated columns will be dropped.
408
413
 
409
- `gitlabds.correlation_reduction(df=None, dv=None, threshold=0.9, method="pearson", verbose=True):`
414
+ `gitlabds.correlation_reduction(df=None, dv=None, threshold=0.9, method="pearson", verbose=True, dropna=True, min_periods=1):`
410
415
 
411
416
  #### Parameters:
412
417
  - _**df**_ : your pandas dataframe
@@ -414,6 +419,8 @@ Reduce the number of columns on a dataframe by dropping columns that are highly
414
419
  - **_threshold_**: The threshold above which columns will be dropped. If two variables exceed this threshold, one will be dropped from the dataframe. For example, the default value of `0.90` will identify columns that have correlations greater than 90% to each other and drop one of those columns.
415
420
  - **_method_**: The correlation method to use. Options are "pearson" (linear relationships), "spearman" (monotonic relationships), or "mutual_info" (any statistical dependency).
416
421
  - **_verbose_** : Set to `True` to print outputs of columns being dropped. Set to `False` to suppress.
422
+ - **_dropna_** : Set to `True` (default) to compare each pair of columns (and each column with the outcome) using only rows where both are non-null. Pearson and Spearman always work this way; for "mutual_info", `False` raises an error if any missing values are present.
423
+ - **_min_periods_** : Minimum number of rows where both columns of a pair are non-null for the pair to be evaluated. Pairs below it are never treated as correlated. Guards against spurious high correlations between sparsely populated columns. "mutual_info" always requires at least 4 such rows.
417
424
 
418
425
  #### Returns
419
426
  - DataFrame with redundant correlated columns dropped.
@@ -434,7 +441,7 @@ new_df = gitlabds.correlation_reduction(df=my_df, dv=None, threshold=0.90, metho
434
441
  #### Description
435
442
  Remove columns that are highly correlated with the outcome (target) column.
436
443
 
437
- `gitlabds.remove_outcome_proxies(df, dv, threshold=.8, method="pearson", verbose=True):`
444
+ `gitlabds.remove_outcome_proxies(df, dv, threshold=.8, method="pearson", verbose=True, dropna=True, min_periods=1):`
438
445
 
439
446
  #### Parameters:
440
447
  - _**df**_ : your pandas dataframe
@@ -442,6 +449,8 @@ Remove columns that are highly correlated with the outcome (target) column.
442
449
  - _**threshold**_ : The correlation value to the outcome above which columns will be dropped. For example, the default value of `0.80` will identify and drop columns that have correlations greater than 80% to the outcome.
443
450
  - **_method_**: The correlation method to use. Options are "pearson" (linear relationships), "spearman" (monotonic relationships), or "mutual_info" (any statistical dependency).
444
451
  - **_verbose_** : Set to `True` to print outputs of columns being dropped. Set to `False` to suppress.
452
+ - **_dropna_** : Set to `True` (default) to compare each column to the outcome using only rows where both are non-null. Pearson and Spearman always work this way; for "mutual_info", `False` raises an error if any missing values are present.
453
+ - **_min_periods_** : Minimum number of rows where both the column and the outcome are non-null for the column to be evaluated. Columns below it are never dropped. Guards against spurious high correlations from sparsely populated columns. "mutual_info" always requires at least 4 such rows.
445
454
 
446
455
  #### Returns
447
456
  - DataFrame with outcome proxy columns dropped.
@@ -455,6 +464,10 @@ new_df = gitlabds.remove_outcome_proxies(df=my_df, dv='my_outcome', threshold=.7
455
464
  # Drop columns with correlations to the outcome greater than 80% using Spearman correlation
456
465
  new_df = gitlabds.remove_outcome_proxies(df=my_df, dv='my_outcome', threshold=.8, method="spearman")
457
466
  ```
467
+ ```python
468
+ # Data with missing values: only evaluate columns with at least 100 non-null rows alongside the outcome
469
+ new_df = gitlabds.remove_outcome_proxies(df=my_df, dv='my_outcome', method="spearman", min_periods=100)
470
+ ```
458
471
  </details>
459
472
 
460
473
  <details><summary> Drop Categorical </summary>
@@ -24,4 +24,5 @@ gitlabds.egg-info/SOURCES.txt
24
24
  gitlabds.egg-info/dependency_links.txt
25
25
  gitlabds.egg-info/requires.txt
26
26
  gitlabds.egg-info/top_level.txt
27
+ tests/test_feature_reduction.py
27
28
  tests/test_snowflake_connectors.py
@@ -0,0 +1,232 @@
1
+ """
2
+ Unit tests for gitlabds.feature_reduction: remove_low_variation, remove_outcome_proxies,
3
+ correlation_reduction.
4
+
5
+ Run with: pytest tests/test_feature_reduction.py
6
+ """
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ import pytest
11
+
12
+ from gitlabds import correlation_reduction, remove_low_variation, remove_outcome_proxies
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # remove_low_variation
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ @pytest.fixture
21
+ def low_variation_df():
22
+ n = 100
23
+ rng = np.random.default_rng(0)
24
+ return pd.DataFrame({
25
+ "target": rng.integers(0, 2, n),
26
+ "varied": rng.normal(size=n),
27
+ "one_value_plus_nan": [1.0] * 83 + [np.nan] * 17,
28
+ "two_values_plus_nan": [1.0] * 50 + [2.0] * 30 + [np.nan] * 20,
29
+ "mostly_nan": [1.0] + [np.nan] * 99,
30
+ "all_null": [np.nan] * n,
31
+ "constant": [5] * n,
32
+ })
33
+
34
+
35
+ class TestRemoveLowVariation:
36
+ def test_default_counts_nan_as_value(self, low_variation_df):
37
+ out = remove_low_variation(low_variation_df, dv="target", verbose=False)
38
+ assert set(out.columns) == {"target", "varied", "one_value_plus_nan", "two_values_plus_nan"}
39
+
40
+ def test_dropna_uses_non_null_values_only(self, low_variation_df):
41
+ out = remove_low_variation(low_variation_df, dv="target", dropna=True, verbose=False)
42
+ assert set(out.columns) == {"target", "varied", "two_values_plus_nan"}
43
+
44
+ @pytest.mark.parametrize("dropna", [False, True])
45
+ def test_all_null_column_dropped(self, low_variation_df, dropna):
46
+ out = remove_low_variation(low_variation_df, dropna=dropna, verbose=False)
47
+ assert "all_null" not in out.columns
48
+
49
+ def test_dropna_threshold_on_non_null_share(self):
50
+ # 97 of 98 non-null rows are 0 (99.0%); counting NaN, 0 is only 97%
51
+ df = pd.DataFrame({"x": [0.0] * 97 + [1.0] + [np.nan] * 2})
52
+ assert "x" in remove_low_variation(df, threshold=0.98, verbose=False).columns
53
+ assert "x" not in remove_low_variation(df, threshold=0.98, dropna=True, verbose=False).columns
54
+
55
+ def test_does_not_mutate_columns_argument(self, low_variation_df):
56
+ cols = ["target", "constant"]
57
+ remove_low_variation(low_variation_df, dv="target", columns=cols, verbose=False)
58
+ assert cols == ["target", "constant"]
59
+
60
+ def test_invalid_dropna(self, low_variation_df):
61
+ with pytest.raises(TypeError):
62
+ remove_low_variation(low_variation_df, dropna="yes")
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # remove_outcome_proxies
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ @pytest.fixture
71
+ def proxy_df():
72
+ n = 500
73
+ rng = np.random.default_rng(1)
74
+ y = rng.normal(size=n)
75
+ df = pd.DataFrame({
76
+ "y": y,
77
+ "proxy": y + rng.normal(scale=0.05, size=n),
78
+ "noise": rng.normal(size=n),
79
+ "weak": 0.3 * y + rng.normal(size=n),
80
+ })
81
+ df["proxy_with_nan"] = df["proxy"].where(rng.random(n) > 0.3)
82
+ df["noise_with_nan"] = df["noise"].where(rng.random(n) > 0.3)
83
+ df["sparse"] = np.nan
84
+ df.loc[:2, "sparse"] = [0.0, 1.0, 2.0]
85
+ df.loc[:2, "y"] = [0.0, 1.0, 2.1]
86
+ return df
87
+
88
+
89
+ class TestRemoveOutcomeProxies:
90
+ @pytest.mark.parametrize("method", ["pearson", "spearman"])
91
+ def test_matches_full_correlation_matrix(self, proxy_df, method):
92
+ expected = proxy_df.corr(method=method)["y"].drop("y").abs()
93
+ expected = set(expected[expected > 0.3].index)
94
+ out = remove_outcome_proxies(proxy_df, dv="y", threshold=0.3, method=method, verbose=False)
95
+ assert set(proxy_df.columns) - set(out.columns) == expected
96
+
97
+ @pytest.mark.parametrize("method", ["pearson", "spearman"])
98
+ def test_min_periods_guards_sparse_columns(self, proxy_df, method):
99
+ # "sparse" has 3 non-null rows that line up perfectly with y
100
+ out = remove_outcome_proxies(proxy_df, dv="y", method=method, verbose=False)
101
+ assert "sparse" not in out.columns
102
+ out = remove_outcome_proxies(proxy_df, dv="y", method=method, min_periods=30, verbose=False)
103
+ assert "sparse" in out.columns
104
+ assert "proxy" not in out.columns
105
+
106
+ def test_mutual_info_handles_nan(self, proxy_df):
107
+ out = remove_outcome_proxies(proxy_df, dv="y", method="mutual_info", threshold=0.5, verbose=False)
108
+ assert "proxy" not in out.columns
109
+ assert "proxy_with_nan" not in out.columns
110
+ assert {"noise", "noise_with_nan", "weak", "sparse"} <= set(out.columns)
111
+
112
+ def test_mutual_info_handles_nan_in_dv(self, proxy_df):
113
+ proxy_df.loc[proxy_df.index[-50:], "y"] = np.nan
114
+ out = remove_outcome_proxies(proxy_df, dv="y", method="mutual_info", threshold=0.5, verbose=False)
115
+ assert "proxy" not in out.columns
116
+ assert "noise" in out.columns
117
+
118
+ def test_mutual_info_dropna_false_raises_on_nan(self, proxy_df):
119
+ with pytest.raises(ValueError, match="dropna=True"):
120
+ remove_outcome_proxies(proxy_df, dv="y", method="mutual_info", dropna=False)
121
+
122
+ def test_mutual_info_dropna_false_without_nan(self, proxy_df):
123
+ df = proxy_df[["y", "proxy", "noise"]]
124
+ out = remove_outcome_proxies(df, dv="y", method="mutual_info", threshold=0.5, dropna=False, verbose=False)
125
+ assert list(out.columns) == ["y", "noise"]
126
+
127
+ def test_mutual_info_classification_dv(self, proxy_df):
128
+ df = proxy_df.assign(y=(proxy_df["y"] > 0).astype(int), proxy=(proxy_df["y"] > 0).astype(float))
129
+ df.loc[df.index[:100], "proxy"] = np.nan
130
+ out = remove_outcome_proxies(df, dv="y", method="mutual_info", threshold=0.5, verbose=False)
131
+ assert "proxy" not in out.columns
132
+ assert "noise" in out.columns
133
+
134
+ @pytest.mark.parametrize("min_periods", [0, 1.5, True])
135
+ def test_invalid_min_periods(self, proxy_df, min_periods):
136
+ with pytest.raises((TypeError, ValueError)):
137
+ remove_outcome_proxies(proxy_df, dv="y", min_periods=min_periods)
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # correlation_reduction
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ @pytest.fixture
146
+ def redundant_df():
147
+ n = 400
148
+ rng = np.random.default_rng(2)
149
+ a = rng.normal(size=n)
150
+ b = rng.normal(size=n)
151
+ df = pd.DataFrame({
152
+ "y": a + rng.normal(scale=0.5, size=n),
153
+ "a1": a + rng.normal(scale=0.1, size=n),
154
+ "a2": a + rng.normal(scale=0.2, size=n),
155
+ "b1": b,
156
+ "b2": b + rng.normal(scale=0.1, size=n),
157
+ "noise": rng.normal(size=n),
158
+ })
159
+ df["a2"] = df["a2"].where(rng.random(n) > 0.2)
160
+ df["b2"] = df["b2"].where(rng.random(n) > 0.2)
161
+ return df
162
+
163
+
164
+ def _reference_drops(df, dv, threshold, method):
165
+ """The pre-vectorization pair search, for pearson/spearman."""
166
+ features = [c for c in df.select_dtypes("number").columns if c != dv]
167
+ corr = df[features].corr(method=method).abs()
168
+ pairs = [
169
+ (corr.iloc[i, j], features[i], features[j])
170
+ for i in range(len(features)) for j in range(i + 1, len(features))
171
+ if corr.iloc[i, j] > threshold
172
+ ]
173
+ pairs.sort(key=lambda p: p[0], reverse=True)
174
+ target = df[features].corrwith(df[dv], method=method).abs() if dv else None
175
+ dropped = set()
176
+ for _, f1, f2 in pairs:
177
+ if f1 in dropped or f2 in dropped:
178
+ continue
179
+ if target is None or target[f1] >= target[f2]:
180
+ dropped.add(f2)
181
+ else:
182
+ dropped.add(f1)
183
+ return dropped
184
+
185
+
186
+ class TestCorrelationReduction:
187
+ @pytest.mark.parametrize("method", ["pearson", "spearman"])
188
+ @pytest.mark.parametrize("dv", [None, "y"])
189
+ def test_matches_reference(self, redundant_df, method, dv):
190
+ out = correlation_reduction(redundant_df, dv=dv, threshold=0.8, method=method, verbose=False)
191
+ dropped = set(redundant_df.columns) - set(out.columns)
192
+ assert dropped == _reference_drops(redundant_df, dv, 0.8, method)
193
+ assert {"a2", "b2"} <= dropped
194
+
195
+ def test_keeps_feature_with_target_correlation_over_nan(self):
196
+ n = 50
197
+ x = np.arange(n, dtype=float)
198
+ df = pd.DataFrame({"y": x, "f1": x, "f2": x})
199
+ # f2's target correlation is NaN (dv constant where f2 overlaps it); f1's is real
200
+ df.loc[: n // 2, "y"] = 1.0
201
+ df.loc[n // 2 + 1:, "f2"] = np.nan
202
+ out = correlation_reduction(df, dv="y", threshold=0.8, verbose=False)
203
+ assert list(out.columns) == ["y", "f1"]
204
+
205
+ def test_min_periods_guards_sparse_pairs(self):
206
+ n = 200
207
+ rng = np.random.default_rng(3)
208
+ df = pd.DataFrame({"s1": np.nan, "s2": np.nan, "x": rng.normal(size=n)}, index=range(n))
209
+ df.loc[:2, "s1"] = [0.0, 1.0, 2.0]
210
+ df.loc[:2, "s2"] = [0.0, 1.0, 2.1]
211
+ assert "s2" not in correlation_reduction(df, verbose=False).columns
212
+ assert list(correlation_reduction(df, min_periods=30, verbose=False).columns) == ["s1", "s2", "x"]
213
+
214
+ @pytest.mark.parametrize("dv", [None, "y"])
215
+ def test_mutual_info_handles_nan(self, redundant_df, dv):
216
+ out = correlation_reduction(redundant_df, dv=dv, threshold=0.5, method="mutual_info", verbose=False)
217
+ assert {"noise", "y"} <= set(out.columns)
218
+ assert not {"b1", "b2"} <= set(out.columns)
219
+
220
+ def test_mutual_info_dropna_false_raises_on_nan(self, redundant_df):
221
+ with pytest.raises(ValueError, match="dropna=True"):
222
+ correlation_reduction(redundant_df, method="mutual_info", dropna=False)
223
+
224
+ def test_mutual_info_dropna_false_without_nan(self, redundant_df):
225
+ df = redundant_df.drop(columns=["a2", "b2"])
226
+ out = correlation_reduction(df, dv="y", threshold=0.5, method="mutual_info", dropna=False, verbose=False)
227
+ assert "noise" in out.columns
228
+
229
+ @pytest.mark.parametrize("min_periods", [0, 1.5, True])
230
+ def test_invalid_min_periods(self, redundant_df, min_periods):
231
+ with pytest.raises((TypeError, ValueError)):
232
+ correlation_reduction(redundant_df, min_periods=min_periods)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes