Repository: alteryx/woodwork (python)

Issue:
Add progress callback to mutual information
- As a user, I wish I could use Woodwork to get the progress of my mutual information calculation.
- The would be turn on via a parameter when running the mutual_info function, called `add_callback`

#### Code Example

```python
import pandas as pd
from woodwork.demo import load_retail

df = load_retail()
df.ww

# function to be called with incremental progress updates
def add_callback(update, overall_progress_percent, overall_time_elapsed):
    # update: percentage change (float between 0 and 100) in progress since last call 
    # overall_progress_percent: percentage (float between 0 and 100) of total computation completed 
    # overall_time_elapsed: total time in seconds that has elapsed since start of call
    pass 

df.ww.mutual_information(add_callback=add_callback)
```


Pull request description:
Add progress callback to mutual information functions
- Add progress callback to mutual information functions
- Closes #938 

Patch:
diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst
index 9bab73e..003bf7e 100644
--- a/docs/source/release_notes.rst
+++ b/docs/source/release_notes.rst
@@ -7,6 +7,7 @@ Future Release
 ==============
     * Enhancements
         * Add option to return ``TableSchema`` instead of ``DataFrame`` from table accessor ``select`` method (:pr:`916`)
+        * Add option to pass progress callback function to mutual information functions (:pr:`943`)
     * Fixes
         * Fix bug when setting table name and metadata through accessor (:pr:`942`)
     * Changes
diff --git a/woodwork/statistics_utils.py b/woodwork/statistics_utils.py
index eb35e12..8d48c42 100644
--- a/woodwork/statistics_utils.py
+++ b/woodwork/statistics_utils.py
@@ -1,10 +1,12 @@
+from timeit import default_timer as timer
+
 import numpy as np
 import pandas as pd
 from sklearn.metrics.cluster import normalized_mutual_info_score
 
 from woodwork.logical_types import Datetime, Double, LatLong
 from woodwork.type_sys.utils import _get_ltype_class
-from woodwork.utils import get_valid_mi_types, import_or_none
+from woodwork.utils import _update_progress, get_valid_mi_types, import_or_none
 
 dd = import_or_none('dask.dataframe')
 ks = import_or_none('databricks.koalas')
@@ -158,7 +160,7 @@ def _make_categorical_for_mutual_info(schema, data, num_bins):
     return data
 
 
-def _get_mutual_information_dict(dataframe, num_bins=10, nrows=None, include_index=False):
+def _get_mutual_information_dict(dataframe, num_bins=10, nrows=None, include_index=False, callback=None):
     """Calculates mutual information between all pairs of columns in the DataFrame that
     support mutual information. Logical Types that support mutual information are as
     follows:  Boolean, Categorical, CountryCode, Datetime, Double, Integer, Ordinal,
@@ -176,6 +178,11 @@ def _get_mutual_information_dict(dataframe, num_bins=10, nrows=None, include_ind
             included as long as its LogicalType is valid for mutual information calculations.
             If False, the index column will not have mutual information calculated for it.
             Defaults to False.
+        callback (callable): function to be called with incremental updates. Has the following parameters:
+
+            - update: percentage change (float between 0 and 100) in progress since last call
+            - progress_percent: percentage (float between 0 and 100) of total computation completed
+            - time_elapsed: total time in seconds that has elapsed since start of call
 
     Returns:
         list(dict): A list containing dictionaries that have keys `column_1`,
@@ -183,6 +190,7 @@ def _get_mutual_information_dict(dataframe, num_bins=10, nrows=None, include_ind
         Mutual information values are between 0 (no mutual information) and 1
         (perfect dependency).
         """
+    start_time = timer()
     valid_types = get_valid_mi_types()
     valid_columns = [col_name for col_name, col in dataframe.ww.columns.items() if _get_ltype_class(col.logical_type) in valid_types]
 
@@ -204,8 +212,17 @@ def _get_mutual_information_dict(dataframe, num_bins=10, nrows=None, include_ind
     if set(not_null_cols) != set(valid_columns):
         data = data.loc[:, not_null_cols]
 
+    # Setup for progress callback and make initial call
+    # Assume 1 unit for preprocessing, n for replace nans, n for make categorical and (n*n+n)/2 for main calculation loop
+    n = len(data.columns)
+    total_loops = 1 + 2 * n + (n * n + n) / 2
+    current_progress = _update_progress(start_time, timer(), 1, 0, total_loops, callback)
+
     data = _replace_nans_for_mutual_info(dataframe.ww.schema, data)
+    current_progress = _update_progress(start_time, timer(), n, current_progress, total_loops, callback)
+
     data = _make_categorical_for_mutual_info(dataframe.ww.schema, data, num_bins)
+    current_progress = _update_progress(start_time, timer(), n, current_progress, total_loops, callback)
 
     # calculate mutual info for all pairs of columns
     mutual_info = []
@@ -215,13 +232,16 @@ def _get_mutual_information_dict(dataframe, num_bins=10, nrows=None, include_ind
             b_col = col_names[j]
             if a_col == b_col:
                 # Ignore because the mutual info for a column with itself will always be 1
+                current_progress = _update_progress(start_time, timer(), 1, current_progress, total_loops, callback)
                 continue
             else:
                 mi_score = normalized_mutual_info_score(data[a_col], data[b_col])
                 mutual_info.append(
                     {"column_1": a_col, "column_2": b_col, "mutual_info": mi_score}
                 )
+                current_progress = _update_progress(start_time, timer(), 1, current_progress, total_loops, callback)
     mutual_info.sort(key=lambda mi: mi['mutual_info'], reverse=True)
+
     return mutual_info
 
 
diff --git a/woodwork/table_accessor.py b/woodwork/table_accessor.py
index 96ed4d5..ef283d0 100644
--- a/woodwork/table_accessor.py
+++ b/woodwork/table_accessor.py
@@ -721,7 +721,7 @@ class WoodworkTableAccessor:
         new_df.ww.init(schema=new_schema)
         return new_df
 
-    def mutual_information_dict(self, num_bins=10, nrows=None, include_index=False):
+    def mutual_information_dict(self, num_bins=10, nrows=None, include_index=False, callback=None):
         """
         Calculates mutual information between all pairs of columns in the DataFrame that
         support mutual information. Logical Types that support mutual information are as
@@ -738,6 +738,11 @@ class WoodworkTableAccessor:
                 included as long as its LogicalType is valid for mutual information calculations.
                 If False, the index column will not have mutual information calculated for it.
                 Defaults to False.
+            callback (callable): function to be called with incremental updates. Has the following parameters:
+
+                - update: percentage change (float between 0 and 100) in progress since last call
+                - progress_percent: percentage (float between 0 and 100) of total computation completed
+                - time_elapsed: total time in seconds that has elapsed since start of call
 
         Returns:
             list(dict): A list containing dictionaries that have keys `column_1`,
@@ -747,9 +752,9 @@ class WoodworkTableAccessor:
         """
         if self._schema is None:
             _raise_init_error()
-        return _get_mutual_information_dict(self._dataframe, num_bins=num_bins, nrows=nrows, include_index=include_index)
+        return _get_mutual_information_dict(self._dataframe, num_bins, nrows, include_index, callback)
 
-    def mutual_information(self, num_bins=10, nrows=None, include_index=False):
+    def mutual_information(self, num_bins=10, nrows=None, include_index=False, callback=None):
         """Calculates mutual information between all pairs of columns in the DataFrame that
         support mutual information. Logical Types that support mutual information are as
         follows:  Age, AgeNullable, Boolean, BooleanNullable, Categorical, CountryCode, Datetime, Double,
@@ -765,6 +770,11 @@ class WoodworkTableAccessor:
                 included as long as its LogicalType is valid for mutual information calculations.
                 If False, the index column will not have mutual information calculated for it.
                 Defaults to False.
+            callback (callable): function to be called with incremental updates. Has the following parameters:
+
+                - update: percentage change (float between 0 and 100) in progress since last call
+                - progress_percent: percentage (float between 0 and 100) of total computation completed
+                - time_elapsed: total time in seconds that has elapsed since start of call
 
         Returns:
             pd.DataFrame: A DataFrame containing mutual information with columns `column_1`,
@@ -772,7 +782,7 @@ class WoodworkTableAccessor:
             Mutual information values are between 0 (no mutual information) and 1
             (perfect dependency).
         """
-        mutual_info = self.mutual_information_dict(num_bins, nrows, include_index)
+        mutual_info = self.mutual_information_dict(num_bins, nrows, include_index, callback)
         return pd.DataFrame(mutual_info)
 
     def describe_dict(self, include=None):
diff --git a/woodwork/utils.py b/woodwork/utils.py
index a61b62e..8a88f7a 100644
--- a/woodwork/utils.py
+++ b/woodwork/utils.py
@@ -298,3 +298,21 @@ def _parse_logical_type(logical_type, name):
         return logical_type
     else:
         raise TypeError(f"Invalid logical type specified for '{name}'")
+
+
+def _update_progress(start_time, current_time, progress_increment,
+                     current_progress, total, callback_function):
+    """Helper function for updating progress of a function and making a call to the progress callback
+    function, if provided. Adds the progress increment to the current progress amount and returns the
+    updated progress amount.
+
+    If provided, the callback function should accept the following parameters:
+        update: percentage change (float between 0 and 100) in progress since last call
+        progress_percent: percentage (float between 0 and 100) of total computation completed
+        time_elapsed: total time in seconds that has elapsed since start of call"""
+    if callback_function is not None:
+        new_progress = current_progress + progress_increment
+        elapsed_time = current_time - start_time
+        callback_function((progress_increment / total) * 100, (new_progress / total) * 100, elapsed_time)
+
+        return new_progress


Test patch:
diff --git a/woodwork/tests/accessor/test_statistics.py b/woodwork/tests/accessor/test_statistics.py
index 8119ab3..bec7a07 100644
--- a/woodwork/tests/accessor/test_statistics.py
+++ b/woodwork/tests/accessor/test_statistics.py
@@ -197,6 +197,40 @@ def test_mutual_info_unique_cols(df_mi_unique):
     assert 'ints' in cols_used
 
 
+def test_mutual_info_callback(df_mi):
+    df_mi.ww.init(logical_types={'dates': Datetime(datetime_format='%Y-%m-%d')})
+
+    class MockCallback:
+        def __init__(self):
+            self.progress_history = []
+            self.total_update = 0
+            self.total_progress_percent = 0
+            self.total_elapsed_time = 0
+
+        def __call__(self, update, progress_percent, time_elapsed):
+            self.total_update += update
+            self.total_progress_percent = progress_percent
+            self.progress_history.append(progress_percent)
+            self.total_elapsed_time = time_elapsed
+
+    mock_callback = MockCallback()
+
+    df_mi.ww.mutual_information(callback=mock_callback)
+
+    # Should be 18 total calls
+    assert len(mock_callback.progress_history) == 18
+
+    # First call should be 1 of 26 units complete
+    assert np.isclose(mock_callback.progress_history[0], 1 / 26 * 100)
+    # After second call should be 6 of 26 units complete
+    assert np.isclose(mock_callback.progress_history[1], 6 / 26 * 100)
+
+    # Should be 100% at end with a positive elapsed time
+    assert np.isclose(mock_callback.total_update, 100.0)
+    assert np.isclose(mock_callback.total_progress_percent, 100.0)
+    assert mock_callback.total_elapsed_time > 0
+
+
 def test_get_describe_dict(describe_df):
     describe_df.ww.init(index='index_col')