Skip to content

Commit db0c469

Browse files
axiomcurapre-commit-ci-lite[bot]gwaybio
authored
Add inverse normal normalization (INT) (cytomining#727)
* added InverseNormalTransform (INT) * added BaseEstimator, TransformerMixin to the InverseNormalTransform class * added InverseNormalTransform to the normalize function * added tests * added test for cli * formatting * added docs * [pre-commit.ci lite] apply automatic fixes * updated docs * applied code rabbit changes * added tests to reflect changes done from code rabbit's recommendation * updated docs: clarify InverseNormalTransform n_quantiles behavior * updated docs * added test to verify default inverse normal quantiles * updated __init__.py * Update pycytominer/operations/transform.py Co-authored-by: Gregory Way <gregory.way@gmail.com> --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Gregory Way <gregory.way@gmail.com>
1 parent 911a589 commit db0c469

7 files changed

Lines changed: 299 additions & 5 deletions

File tree

pycytominer/cli.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ def normalize(
206206
spherize_center: bool = True,
207207
spherize_method: str = "ZCA-cor",
208208
spherize_epsilon: float = 1e-6,
209+
inverse_normal_n_quantiles: int = 1000,
209210
) -> str:
210211
"""Normalize profiles from a file and write the results to disk.
211212
@@ -225,6 +226,9 @@ def normalize(
225226
spherize_center: Whether to center data before sphering.
226227
spherize_method: Spherize method to use.
227228
spherize_epsilon: Spherize epsilon parameter.
229+
inverse_normal_n_quantiles: Number of cumulative distribution function
230+
landmarks used for inverse normal normalization. Values larger than
231+
the number of samples are capped at the number of samples.
228232
229233
Returns:
230234
The output file path.
@@ -254,6 +258,7 @@ def normalize(
254258
spherize_center=spherize_center,
255259
spherize_method=spherize_method,
256260
spherize_epsilon=spherize_epsilon,
261+
inverse_normal_n_quantiles=inverse_normal_n_quantiles,
257262
)
258263
if isinstance(result, str):
259264
_announce_output_file(result)

pycytominer/normalize.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pycytominer.cyto_utils.features import infer_cp_features
1212
from pycytominer.cyto_utils.load import load_profiles
1313
from pycytominer.cyto_utils.util import write_to_file_if_user_specifies_output_details
14-
from pycytominer.operations import RobustMAD, Spherize
14+
from pycytominer.operations import InverseNormalTransform, RobustMAD, Spherize
1515

1616

1717
@write_to_file_if_user_specifies_output_details
@@ -33,6 +33,7 @@ def normalize(
3333
spherize_center: bool = True,
3434
spherize_method: str = "ZCA-cor",
3535
spherize_epsilon: float = 1e-6,
36+
inverse_normal_n_quantiles: int = 1000,
3637
) -> pd.DataFrame:
3738
"""Normalize profiling features
3839
@@ -103,6 +104,10 @@ def normalize(
103104
spherize_epsilon : float, default 1e-6.
104105
The sphering (aka whitening) fudge factor parameter. The function only uses
105106
this variable if method = "spherize".
107+
inverse_normal_n_quantiles : int, default=1000
108+
Number of cumulative distribution function landmarks used for the inverse
109+
normal transformation. Values larger than the number of samples are capped
110+
at the number of samples. Only used when ``method="inverse_normal"``.
106111
107112
Returns
108113
-------
@@ -187,7 +192,13 @@ def normalize(
187192
# Define which scaler to use
188193
method = method.lower()
189194

190-
avail_methods = ["standardize", "robustize", "mad_robustize", "spherize"]
195+
avail_methods = [
196+
"standardize",
197+
"robustize",
198+
"mad_robustize",
199+
"spherize",
200+
"inverse_normal",
201+
]
191202
if method not in avail_methods:
192203
raise ValueError(f"operation must be one {avail_methods}")
193204

@@ -206,6 +217,8 @@ def normalize(
206217
epsilon=spherize_epsilon,
207218
return_numpy=True,
208219
)
220+
elif method == "inverse_normal":
221+
scaler = InverseNormalTransform(n_quantiles=inverse_normal_n_quantiles)
209222

210223
if features == "infer":
211224
features = infer_cp_features(profiles, image_features=image_features)

pycytominer/operations/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
from .frequency_threshold import calculate_frequency, frequency_threshold
33
from .get_na_columns import get_na_columns
44
from .noise_removal import noise_removal
5-
from .transform import RobustMAD, Spherize
5+
from .transform import InverseNormalTransform, RobustMAD, Spherize
66
from .variance_threshold import variance_threshold

pycytominer/operations/transform.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import pandas as pd
1212
from scipy.stats import median_abs_deviation
1313
from sklearn.base import BaseEstimator, TransformerMixin
14-
from sklearn.preprocessing import StandardScaler
14+
from sklearn.preprocessing import QuantileTransformer, StandardScaler
1515

1616
Spherize_type = TypeVar("Spherize_type", bound="Spherize")
1717
RobustMAD_type = TypeVar("RobustMAD_type", bound="RobustMAD")
@@ -338,3 +338,92 @@ def transform(self, X: pd.DataFrame, copy: Optional[bool] = None) -> pd.DataFram
338338
RobustMAD transformed dataframe
339339
"""
340340
return (X - self.median) / (self.mad + self.epsilon)
341+
342+
343+
class InverseNormalTransform(BaseEstimator, TransformerMixin):
344+
"""Inverse normal transform.
345+
346+
Apply a rank-based quantile transformation to each feature independently
347+
and map the resulting values to a normal distribution.
348+
349+
1) Rank the values of each feature independently.
350+
2) Map the ranks to quantiles of a normal distribution.
351+
3) Return the transformed values.
352+
353+
This class wraps sklearn.preprocessing.QuantileTransformer with
354+
output_distribution="normal".
355+
356+
Parameters
357+
----------
358+
n_quantiles : int, default=1000
359+
Number of quantiles to be computed. It corresponds to the number of landmarks
360+
used to discretize the cumulative distribution function. If ``n_quantiles`` is
361+
larger than the number of samples, it is set to the number of samples because
362+
a larger number of quantiles does not improve the cumulative distribution
363+
function estimate. The actual number used after fitting is available as
364+
``n_quantiles_``. See sklearn.preprocessing.QuantileTransformer for more details.
365+
random_state : int, RandomState instance or None, default=None
366+
Determines random number generation for smoothing noise. Pass an int for
367+
reproducible results across multiple calls.
368+
369+
Notes
370+
-----
371+
This transform is rank-based: values are first converted to quantile ranks,
372+
then mapped to a normal distribution. The transformed values are therefore
373+
normal scores, not the original raw measurements, and distances between raw
374+
values are not preserved.
375+
"""
376+
377+
def __init__(
378+
self,
379+
n_quantiles=1000,
380+
random_state=None,
381+
):
382+
self.n_quantiles = n_quantiles
383+
self.random_state = random_state
384+
385+
def fit(self, x, y=None):
386+
"""Fit inverse normal transform.
387+
388+
Parameters
389+
----------
390+
x : pandas.DataFrame or numpy.ndarray
391+
Data to fit.
392+
y : None
393+
Has no effect; only used for consistency in sklearn transform API
394+
395+
Returns
396+
-------
397+
self
398+
Fitted inverse normal transform.
399+
"""
400+
# Set number of quantiles, if n_quantiles is greater than the number of samples\
401+
# set it to the number of samples.
402+
self.n_quantiles_ = min(self.n_quantiles, x.shape[0])
403+
404+
# Initialize transformer and set output distribution to normal.
405+
# We set it to normal because we want to map the ranks to a normal distribution.
406+
self.transformer_ = QuantileTransformer(
407+
n_quantiles=self.n_quantiles_,
408+
output_distribution="normal",
409+
random_state=self.random_state,
410+
)
411+
412+
self.transformer_.fit(x)
413+
414+
return self
415+
416+
def transform(self, x):
417+
"""Apply inverse normal transform.
418+
419+
Parameters
420+
----------
421+
x : pandas.DataFrame or numpy.ndarray
422+
Data to transform.
423+
424+
Returns
425+
-------
426+
numpy.ndarray
427+
Transformed data.
428+
"""
429+
return self.transformer_.transform(x)

tests/test_cli.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import numpy as np
99
import pandas as pd
1010
import pytest
11+
from sklearn.preprocessing import QuantileTransformer
1112

1213
from pycytominer import cli as pycytominer_cli
1314
from pycytominer.cli import PycytominerCLI, PycytominerCLIError
@@ -103,6 +104,36 @@ def test_cli_normalize(tmp_path: pathlib.Path) -> None:
103104
assert np.isclose(result["Feature_2"].mean(), 0.0, atol=1e-7)
104105

105106

107+
def test_cli_normalize_inverse_normal(tmp_path: pathlib.Path) -> None:
108+
"""Ensure CLI normalize forwards inverse normal options."""
109+
df, profiles_path = _write_profiles(tmp_path)
110+
output_path = tmp_path / "normalized_inverse_normal.csv"
111+
112+
cli = PycytominerCLI()
113+
cli.normalize(
114+
profiles=str(profiles_path),
115+
output_file=str(output_path),
116+
features="Feature_1,Feature_2",
117+
meta_features="Metadata_Plate,Metadata_Well",
118+
method="inverse_normal",
119+
inverse_normal_n_quantiles=3,
120+
)
121+
122+
result = pd.read_csv(output_path)
123+
expected_features = QuantileTransformer(
124+
n_quantiles=3,
125+
output_distribution="normal",
126+
).fit_transform(df.loc[:, ["Feature_1", "Feature_2"]])
127+
128+
assert result.loc[:, ["Metadata_Plate", "Metadata_Well"]].equals(
129+
df.loc[:, ["Metadata_Plate", "Metadata_Well"]]
130+
)
131+
np.testing.assert_allclose(
132+
result.loc[:, ["Feature_1", "Feature_2"]],
133+
expected_features,
134+
)
135+
136+
106137
def test_cli_normalize_drop_cosmicqc_rows(tmp_path: pathlib.Path) -> None:
107138
"""Ensure CLI normalize forwards drop_cosmicqc_rows."""
108139
profiles = pd.DataFrame({

tests/test_normalize.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import numpy as np
66
import pandas as pd
77
import pytest
8+
from sklearn.preprocessing import QuantileTransformer
89

910
from pycytominer.normalize import normalize
1011

@@ -702,6 +703,83 @@ def test_normalize_robustize_mad_allsamples_novar():
702703
pd.testing.assert_frame_equal(normalize_result, expected_result)
703704

704705

706+
def test_normalize_inverse_normal_inferred_image_profile_features():
707+
"""
708+
Testing normalize pycytominer function
709+
method = "inverse_normal"
710+
features = "infer"
711+
samples = "all"
712+
"""
713+
# Set features to the inferred image profile features
714+
features = ["Cells_x", "Cells_y", "Cytoplasm_z", "Nuclei_zz"]
715+
716+
# Run normalize with the inferred features
717+
normalize_result = normalize(
718+
profiles=data_feature_infer_df.copy(),
719+
features="infer",
720+
meta_features=["Metadata_plate", "Metadata_treatment"],
721+
samples="all",
722+
method="inverse_normal",
723+
inverse_normal_n_quantiles=5,
724+
)
725+
726+
# Compute the expected result using QuantileTransformer with the same parameters
727+
expected_features = QuantileTransformer(
728+
n_quantiles=5,
729+
output_distribution="normal",
730+
).fit_transform(data_feature_infer_df.loc[:, features])
731+
expected_result = pd.concat(
732+
[
733+
data_feature_infer_df.loc[:, ["Metadata_plate", "Metadata_treatment"]],
734+
pd.DataFrame(expected_features, columns=features),
735+
],
736+
axis="columns",
737+
)
738+
739+
pd.testing.assert_frame_equal(normalize_result, expected_result)
740+
741+
742+
def test_normalize_inverse_normal_control_samples():
743+
"""
744+
Testing normalize pycytominer function
745+
method = "inverse_normal"
746+
samples = "Metadata_treatment == 'control'"
747+
"""
748+
749+
# Set features to the inferred image profile features
750+
# and add a control query to select only control samples
751+
features = ["Cells_x", "Cells_y", "Cytoplasm_z", "Nuclei_zz"]
752+
control_query = "Metadata_treatment == 'control'"
753+
754+
# Run normalize with the inferred features and control samples
755+
normalize_result = normalize(
756+
profiles=data_feature_infer_df.copy(),
757+
features=features,
758+
meta_features=["Metadata_plate", "Metadata_treatment"],
759+
samples=control_query,
760+
method="inverse_normal",
761+
inverse_normal_n_quantiles=3,
762+
)
763+
764+
# Set up the expected result using QuantileTransformer with the same parameters
765+
expected_scaler = QuantileTransformer(
766+
n_quantiles=3,
767+
output_distribution="normal",
768+
).fit(data_feature_infer_df.query(control_query).loc[:, features])
769+
expected_features = expected_scaler.transform(
770+
data_feature_infer_df.loc[:, features]
771+
)
772+
expected_result = pd.concat(
773+
[
774+
data_feature_infer_df.loc[:, ["Metadata_plate", "Metadata_treatment"]],
775+
pd.DataFrame(expected_features, columns=features),
776+
],
777+
axis="columns",
778+
)
779+
780+
pd.testing.assert_frame_equal(normalize_result, expected_result)
781+
782+
705783
def test_normalize_standardize_allsamples_fromfile():
706784
"""
707785
Testing normalize pycytominer function

0 commit comments

Comments
 (0)