Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] Empirical CDF calculation
Because @ericmjl & I keep copypasta'ing it across all my notebooks

```python
def calc_single_ecdf(data):
    """Compute the Empirical Cumulative Distribution Function for an Array."""
    # Number of points n
    n = len(data)

    # x data for the CDF 
    x = np.sort(data)

    # y data for the CDF
    y = np.arange(1, n + 1 ) / n

    return x, y
```


Discussion:
Does this handle NaNs correctly? I think the denominator should be notnull.sum() right?
@jiafengkevinchen yes, the function can be improved to handle nulls. There shouldn't be a sum, though; an ECDF displays cumulative fraction of points that are at or below a given value. Either there should be a check for no nulls in `data`, or we drop nulls automagically. I'm leaning to dropping nulls automagically, for this means setting a sane default on the data.
> @jiafengkevinchen yes, the function can be improved to handle nulls. There shouldn't be a sum, though; an ECDF displays cumulative fraction of points that are at or below a given value. Either there should be a check for no nulls in `data`, or we drop nulls automagically. I'm leaning to dropping nulls automagically, for this means setting a sane default on the data.

Incidentally, I think there's an ECDF function in statsmodels, but it also doesn't handle nulls correctly. Since ECDFs are easy to compute, we should just code it up like the suggested implementations

Resolving patch:
diff --git a/janitor/functions.py b/janitor/functions.py
--- a/janitor/functions.py
+++ b/janitor/functions.py
@@ -20,10 +20,9 @@
 
 import numpy as np
 import pandas as pd
-from pandas.errors import OutOfBoundsDatetime
-
 import pandas_flavor as pf
 from pandas.api.types import union_categoricals
+from pandas.errors import OutOfBoundsDatetime
 from scipy.stats import mode
 from sklearn.preprocessing import LabelEncoder
 
diff --git a/janitor/math.py b/janitor/math.py
--- a/janitor/math.py
+++ b/janitor/math.py
@@ -6,6 +6,7 @@
 import numpy as np
 import pandas as pd
 import pandas_flavor as pf
+from pandas.api.types import is_numeric_dtype
 from scipy.special import expit
 from scipy.stats import norm
 
@@ -129,7 +130,9 @@ def probit(s: pd.Series, error: str = "warn") -> pd.Series:
 
 @pf.register_series_method
 def z_score(
-    s: pd.Series, moments_dict: dict = None, keys: Tuple[str] = ("mean", "std")
+    s: pd.Series,
+    moments_dict: dict = None,
+    keys: Tuple[str, str] = ("mean", "std"),
 ) -> pd.Series:
     """
     Transforms the Series into z-scores
@@ -155,3 +158,43 @@ def z_score(
         moments_dict[keys[0]] = mean
         moments_dict[keys[1]] = std
     return (s - mean) / std
+
+
+@pf.register_series_method
+def ecdf(s: pd.Series) -> Tuple[np.ndarray, np.ndarray]:
+    """
+    Return cumulative distribution of values in a series.
+
+    Intended to be used with the following pattern:
+
+    .. code-block:: python
+
+        df = pd.DataFrame(...)
+
+        # Obtain ECDF values to be plotted
+        x, y = df["column_name"].ecdf()
+
+        # Plot ECDF values
+        plt.scatter(x, y)
+
+    Null values must be dropped from the series,
+    otherwise a ``ValueError`` is raised.
+
+    Also, if the dtype of the series is not numeric,
+    a TypeError is raised.
+
+    :param s: A pandas series. dtype should be numeric.
+    :returns: (x, y).
+        x: sorted array of values.
+        y: cumulative fraction of data points with value ``x`` or lower.
+    """
+    if not is_numeric_dtype(s):
+        raise TypeError(f"series {s.name} must be numeric!")
+    if not s.isnull().sum() == 0:
+        raise ValueError(f"series {s.name} contains nulls. Please drop them.")
+
+    n = len(s)
+    x = np.sort(s)
+    y = np.arange(1, n + 1) / n
+
+    return x, y
diff --git a/janitor/xarray/functions.py b/janitor/xarray/functions.py
--- a/janitor/xarray/functions.py
+++ b/janitor/xarray/functions.py
@@ -4,11 +4,10 @@
 """
 
 
-import xarray as xr
-import numpy as np
-
 from typing import Union
 
+import numpy as np
+import xarray as xr
 from pandas_flavor import (
     register_xarray_dataarray_method,
     register_xarray_dataset_method,


Test patch:
diff --git a/tests/math/test_ecdf.py b/tests/math/test_ecdf.py
new file mode 100644
--- /dev/null
+++ b/tests/math/test_ecdf.py
@@ -0,0 +1,23 @@
+import numpy as np
+import pytest
+from hypothesis import given
+from hypothesis import strategies as st
+from hypothesis.extra.pandas import series
+
+
+@given(s=series(dtype=np.number))
+def test_ecdf(s):
+    """A simple execution test."""
+    if s.isnull().sum() > 0:
+        with pytest.raises(ValueError):
+            x, y = s.ecdf()
+    else:
+        x, y = s.ecdf()
+        assert len(x) == len(y)
+
+
+@given(s=series(dtype=str))
+def test_ecdf_string(s):
+    """Test that type enforcement is in place."""
+    with pytest.raises(TypeError):
+        x, y = s.ecdf()
diff --git a/tests/xarray/conftest.py b/tests/xarray/conftest.py
--- a/tests/xarray/conftest.py
+++ b/tests/xarray/conftest.py
@@ -1,6 +1,6 @@
+import numpy as np
 import pytest
 import xarray as xr
-import numpy as np
 
 
 @pytest.fixture
diff --git a/tests/xarray/test_clone_using.py b/tests/xarray/test_clone_using.py
--- a/tests/xarray/test_clone_using.py
+++ b/tests/xarray/test_clone_using.py
@@ -1,6 +1,6 @@
-import xarray as xr
 import numpy as np
 import pytest
+import xarray as xr
 
 import janitor  # noqa: F401
 
diff --git a/tests/xarray/test_convert_datetime_to_number.py b/tests/xarray/test_convert_datetime_to_number.py
--- a/tests/xarray/test_convert_datetime_to_number.py
+++ b/tests/xarray/test_convert_datetime_to_number.py
@@ -1,6 +1,6 @@
 import numpy as np
-import xarray as xr
 import pytest
+import xarray as xr
 
 
 @pytest.mark.xarray