Repository: pyjanitor-devs/pyjanitor
Pull request #1084 (2022-05-02 20:18:50)

[DOC] doctests in biology #972
<!-- Thank you for your PR!

BEFORE YOU CONTINUE! Please add the appropriate three-letter abbreviation to your title.

The abbreviations can be:
- [DOC]: Documentation fixes.
- [ENH]: Code contributions and new features.
- [TST]: Test-related contributions.
- [INF]: Infrastructure-related contributions.

Also, do not forget to tag the relevant issue here as well.

Finally, as commits come in, don't forget to regularly rebase!
-->

# PR Description

Please describe the changes proposed in the pull request:

- Convert docstring examples in biology.py to doctests
- Remove functional example, keep method-chaining
- Part of #972 

<!-- Doing so provides maintainers with context on what the PR is, and can help us more effectively review your PR. -->

<!-- Please also identify below which issue that has been raised that you are going to close. -->

**This PR resolves #(put issue number here, and remove parentheses).**

<!-- As you go down the PR template, please feel free to delete sections that are irrelevant. -->

# PR Checklist

<!-- This checklist exists for newcomers who are not yet familiar with our requirements. If you are experienced with
the project, please feel free to delete this section. -->

Please ensure that you have done the following:

1. [ x] PR in from a fork off your branch. Do not PR from `<your_username>`:`dev`, but rather from `<your_username>`:`<feature-branch_name>`.
<!-- Doing this helps us keep the commit history much cleaner than it would otherwise be. -->
2. [x] If you're not on the contributors list, add yourself to `AUTHORS.md`.
<!-- We'd like to acknowledge your contributions! -->
3. [na] Add a line to `CHANGELOG.md` under the latest version header (i.e. the one that is "on deck") describing the contribution.
    - Do use some discretion here; if there are multiple PRs that are related, keep them in a single line.

# Automatic checks

There will be automatic checks run on the PR. These include:

- Building a preview of the docs on Netlify
- Automatically linting the code
- Making sure the code is documented
- Making sure that all tests are passed
- Making sure that code coverage doesn't go down.

# Relevant Reviewers

<!-- Finally, please tag relevant maintainers to review. -->

Please tag maintainers to review.

- @ericmjl



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21b0534..2aabc30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@
 -   [DOC] Added a step in the dev guide to install `Remote Container` in VS Code. @ashenafiyb
 -   [DOC] Convert `expand_column` and `find_replace` code examples to doctests, issue #972. @gahjelle
 -   [DOC] Convert `engineering` code examples to doctests, issue #972 @ashenafiyb
+-   [DOC] Add doctests to `math`, issue #972. @gahjelle
 
 ## [v0.22.0] - 2021-11-21
 
diff --git a/janitor/biology.py b/janitor/biology.py
index 07da6dd..41465ed 100644
--- a/janitor/biology.py
+++ b/janitor/biology.py
@@ -39,36 +39,34 @@ def join_fasta(
 
     For more advanced functions, please use phylopandas.
 
-    Functional usage example:
-
-    ```python
-    import pandas as pd
-    import janitor.biology
-
-    df = pd.DataFrame(...)
-
-    df = janitor.biology.join_fasta(
-        df=df,
-        filename='fasta_file.fasta',
-        id_col='sequence_accession',
-        column_name='sequence',
-    )
-    ```
-
     Method chaining usage example:
 
-    ```python
-    import pandas as pd
-    import janitor.biology
-
-    df = pd.DataFrame(...)
-
-    df = df.join_fasta(
-        filename='fasta_file.fasta',
-        id_col='sequence_accession',
-        column_name='sequence',
-    )
-    ```
+    >>> import tempfile
+    >>> import pandas as pd
+    >>> import janitor.biology
+
+    >>> tf = tempfile.NamedTemporaryFile()
+    >>> tf.write('''>SEQUENCE_1
+    ... MTEITAAMVKELRESTGAGMMDCK
+    ... >SEQUENCE_2
+    ... SATVSEINSETDFVAKN'''.encode('utf8'))
+    66
+    >>> tf.seek(0)
+    0
+
+    >>> df = pd.DataFrame({"sequence_accession":
+    ... ["SEQUENCE_1", "SEQUENCE_2", ]})
+
+    >>> df = df.join_fasta(
+    ...     filename=tf.name,
+    ...     id_col='sequence_accession',
+    ...     column_name='sequence',
+    ... )
+
+    >>> df.sequence
+    0    MTEITAAMVKELRESTGAGMMDCK
+    1           SATVSEINSETDFVAKN
+    Name: sequence, dtype: object
 
     :param df: A pandas DataFrame.
     :param filename: Path to the FASTA file.
diff --git a/janitor/math.py b/janitor/math.py
index 3bfedac..7a023c2 100644
--- a/janitor/math.py
+++ b/janitor/math.py
@@ -8,6 +8,7 @@ import pandas as pd
 import pandas_flavor as pf
 from pandas.api.types import is_numeric_dtype
 from scipy.special import expit
+from scipy.special import logit as scipy_logit
 from scipy.special import softmax as scipy_softmax
 from scipy.stats import norm
 
@@ -17,6 +18,18 @@ def log(s: pd.Series, error: str = "warn") -> pd.Series:
     """
     Take natural logarithm of the Series.
 
+    Each value in the series should be positive. Use `error` to control the
+    behavior if there are nonpositive entries in the series.
+
+        >>> import pandas as pd
+        >>> import janitor
+        >>> s = pd.Series([0, 1, 3], name="numbers")
+        >>> s.log(error="ignore")
+        0         NaN
+        1    0.000000
+        2    1.098612
+        Name: numbers, dtype: float64
+
     :param s: Input Series.
     :param error: Determines behavior when taking the log of nonpositive
         entries. If `'warn'` then a `RuntimeWarning` is thrown. If `'raise'`,
@@ -45,6 +58,15 @@ def exp(s: pd.Series) -> pd.Series:
     """
     Take the exponential transform of the series.
 
+        >>> import pandas as pd
+        >>> import janitor
+        >>> s = pd.Series([0, 1, 3], name="numbers")
+        >>> s.exp()
+        0     1.000000
+        1     2.718282
+        2    20.085537
+        Name: numbers, dtype: float64
+
     :param s: Input Series.
     :return: Transformed Series.
     """
@@ -60,6 +82,15 @@ def sigmoid(s: pd.Series) -> pd.Series:
     sigmoid(x) = 1 / (1 + exp(-x))
     ```
 
+        >>> import pandas as pd
+        >>> import janitor
+        >>> s = pd.Series([-1, 0, 4], name="numbers")
+        >>> s.sigmoid()
+        0    0.268941
+        1    0.500000
+        2    0.982014
+        Name: numbers, dtype: float64
+
     :param s: Input Series.
     :return: Transformed Series.
     """
@@ -81,6 +112,15 @@ def softmax(s: pd.Series) -> pd.Series:
     softmax(x) = exp(x)/sum(exp(x))
     ```
 
+        >>> import pandas as pd
+        >>> import janitor
+        >>> s = pd.Series([0, 1, 3], name="numbers")
+        >>> s.softmax()
+        0    0.042010
+        1    0.114195
+        2    0.843795
+        Name: numbers, dtype: float64
+
     :param s: Input Series.
     :return: Transformed Series.
     """
@@ -96,8 +136,20 @@ def logit(s: pd.Series, error: str = "warn") -> pd.Series:
     logit(p) = log(p/(1-p))
     ```
 
+    Each value in the series should be between 0 and 1. Use `error` to
+    control the behavior if any series entries are outside of (0, 1).
+
+        >>> import pandas as pd
+        >>> import janitor
+        >>> s = pd.Series([0.1, 0.5, 0.9], name="numbers")
+        >>> s.logit()
+        0   -2.197225
+        1    0.000000
+        2    2.197225
+        Name: numbers, dtype: float64
+
     :param s: Input Series.
-    :param error: Determines behavior when `s / (1-s)` is outside of `(0, 1)`.
+    :param error: Determines behavior when `s` is outside of `(0, 1)`.
         If `'warn'` then a `RuntimeWarning` is thrown. If `'raise'`, then a
         `RuntimeError` is thrown. Otherwise, nothing is thrown and `np.nan`
         is returned for the problematic entries; defaults to `'warn'`.
@@ -105,19 +157,17 @@ def logit(s: pd.Series, error: str = "warn") -> pd.Series:
     :raises RuntimeError: if `error` is set to `'raise'`.
     """
     s = s.copy()
-    odds_ratio = s / (1 - s)
-    outside_support = (odds_ratio <= 0) | (odds_ratio >= 1)
+    outside_support = (s <= 0) | (s >= 1)
     if (outside_support).any():
-        msg = f"Odds ratio for {outside_support.sum()} value(s) \
-are outside of (0, 1)"
+        msg = f"{outside_support.sum()} value(s) are outside of (0, 1)"
         if error.lower() == "warn":
             warnings.warn(msg, RuntimeWarning)
         if error.lower() == "raise":
             raise RuntimeError(msg)
         else:
             pass
-    odds_ratio[outside_support] = np.nan
-    return odds_ratio.log(error="ignore")
+    s[outside_support] = np.nan
+    return scipy_logit(s)
 
 
 @pf.register_series_method
@@ -125,6 +175,15 @@ def normal_cdf(s: pd.Series) -> pd.Series:
     """
     Transforms the Series via the CDF of the Normal distribution.
 
+        >>> import pandas as pd
+        >>> import janitor
+        >>> s = pd.Series([-1, 0, 3], name="numbers")
+        >>> s.normal_cdf()
+        0    0.158655
+        1    0.500000
+        2    0.998650
+        dtype: float64
+
     :param s: Input Series.
     :return: Transformed Series.
     """
@@ -136,6 +195,18 @@ def probit(s: pd.Series, error: str = "warn") -> pd.Series:
     """
     Transforms the Series via the inverse CDF of the Normal distribution.
 
+    Each value in the series should be between 0 and 1. Use `error` to
+    control the behavior if any series entries are outside of (0, 1).
+
+        >>> import pandas as pd
+        >>> import janitor
+        >>> s = pd.Series([0.1, 0.5, 0.8], name="numbers")
+        >>> s.probit()
+        0   -1.281552
+        1    0.000000
+        2    0.841621
+        dtype: float64
+
     :param s: Input Series.
     :param error: Determines behavior when `s` is outside of `(0, 1)`.
         If `'warn'` then a `RuntimeWarning` is thrown. If `'raise'`, then
@@ -174,6 +245,15 @@ def z_score(
     z = (s - s.mean()) / s.std()
     ```
 
+        >>> import pandas as pd
+        >>> import janitor
+        >>> s = pd.Series([0, 1, 3], name="numbers")
+        >>> s.z_score()
+        0   -0.872872
+        1   -0.218218
+        2    1.091089
+        Name: numbers, dtype: float64
+
     :param s: Input Series.
     :param moments_dict: If not `None`, then the mean and standard
         deviation used to compute the z-score transformation is
@@ -216,6 +296,15 @@ def ecdf(s: pd.Series) -> Tuple[np.ndarray, np.ndarray]:
     Also, if the `dtype` of the series is not numeric,
     a `TypeError` is raised.
 
+        >>> import pandas as pd
+        >>> import janitor
+        >>> df = pd.DataFrame({"numbers": [0, 4, 0, 1, 2, 1, 1, 3]})
+        >>> x, y = df["numbers"].ecdf()
+        >>> x
+        array([0, 0, 1, 1, 1, 2, 3, 4])
+        >>> y
+        array([0.125, 0.25 , 0.375, 0.5  , 0.625, 0.75 , 0.875, 1.   ])
+
     :param s: A pandas series. `dtype` should be numeric.
     :returns: `(x, y)`.
         `x`: sorted array of values.


Test patch:
diff --git a/tests/math/test_logit.py b/tests/math/test_logit.py
index a6f52b5..0ae84d3 100644
--- a/tests/math/test_logit.py
+++ b/tests/math/test_logit.py
@@ -5,9 +5,9 @@ import pytest
 
 @pytest.mark.functions
 def test_logit():
-    s = pd.Series([0, 0.1, 0.2, 0.3, 1, 2])
+    s = pd.Series([0, 0.1, 0.2, 0.3, 0.5, 0.9, 1, 2])
     inside = (0 < s) & (s < 1)
-    valid = np.array([0.1, 0.2, 0.3])
+    valid = np.array([0.1, 0.2, 0.3, 0.5, 0.9])
     ans = np.log(valid / (1 - valid))
 
     with pytest.raises(RuntimeError):
@@ -17,13 +17,13 @@ def test_logit():
         out = s.logit(error="warn")
 
     assert out[inside].notna().all()
-    assert (out[inside] == ans).all()
+    assert out[inside].to_numpy() == pytest.approx(ans)
     assert (out.index == s.index).all()
     assert out[~inside].isna().all()
 
     out = s.logit(error="ignore")
 
     assert out[inside].notna().all()
-    assert (out[inside] == ans).all()
+    assert out[inside].to_numpy() == pytest.approx(ans)
     assert (out.index == s.index).all()
     assert out[~inside].isna().all()