Repository: IAMconsortium/pyam
Pull request #174 (2019-01-22 13:01:46)

add a function `compare()` to compare two IamDataFrames
# Please confirm that this PR has done the following:

- [x] Tests Added
- [x] Documentation Added
- [x] Description in RELEASE_NOTES.md Added

# Description of PR

This PR introduces a function `compare()` to compare the `data` tables of two IamDataFrames using the `np.isclose()` function.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/21206367/badge)](https://coveralls.io/builds/21206367)

Coverage increased (+0.05%) to 83.931% when pulling **0b25572d5b53782d29d82109f4b882907db9124d on danielhuppmann:difference** into **4c74d1ca1654c8dc3e54865c3d40cd017bf89bd2 on IAMconsortium:master**.

gidden: lgtm, one suggestion though: should we add an additional option(s)? Specifically, I'm thinking difference, relative difference, and anything else? so you would see the difference in the test is `(2 - 1) = 1` and `(2 - 1) / 2 = 0.5`?
danielhuppmann: The comparison is using `np.isclose()` and you can pass the kwargs to it through the `compare()` function. So absolute and relative tolerance can be specified, though you have to keep in mind that `np.isclose()` is not symmetric, so `np.isclose(a, b)` does not necessary equal `np.isclose(b, a)`. Still, this is good enough for my use case...

@gidden, can you specify a use case that requires a more elaborate treatment?
gidden: I see that `np.isclose` is used. I was suggesting two separate things I suppose:

1. the definition of close could be in absolute magnitude or relative (within 50%, for example)
2. it is likely useful to see what the difference is, thus adding a column which provides `a - b` in addition to `a` and `b`

Another thought is this API/feel is close to looking like [`pd.subtract`](https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.subtract.html) and could be confused with `pd.diff`.

Perhaps we should take the pandas route and supply a `subtract` function. Then we could make a `compare` function which houses what you describe here. One possible function signature could be:

```
pyam.compare(left, right, left_label='left', right_label='right', kind='diff', drop_close=True, **kwargs)
```
danielhuppmann: rebased to resolve merge conflicts
danielhuppmann: I see the point of broader `compare()` function (not so sure that subtracting makes sense or that it can be confused with  'first-difference' as in `pd.diff()`).

Anyway, I refactored to `compare()` and added `drop_close` as a kwarg. Other kwargs such as `kind` (or maybe `method`?) can be added at a later point.
danielhuppmann: Another idea for a use case of this function (jotted down here for future reference):

`pyam.compare(df, on={'scenario': ('scenario_a', 'scenario_b')})`

which would show the difference between two scenarios with different names within on IamDataFrame (whereas the current implementation compares scenarios with the same name across two IamDataFrames).
gidden: Awesome, thanks!

Patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index f86de86..74c3ce7 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -2,6 +2,7 @@
 # Next Release
 
 - [#175](https://github.com/IAMconsortium/pyam/pull/175) Update link to tutorial in readme.md 
+- [#174](https://github.com/IAMconsortium/pyam/pull/174) Add a function `difference()` to compare two IamDataFrames
 - [#171](https://github.com/IAMconsortium/pyam/pull/171) Fix a bug when reading from an `ixmp.TimeSeries` object, refactor to mitigate circular dependency
 - [#162](https://github.com/IAMconsortium/pyam/pull/162) Add a function to sum and append timeseries components to an aggregate variable
 - [#152](https://github.com/IAMconsortium/pyam/pull/152) Fix bug where scatter plots did not work with property metadata when using two variables (#136, #152)
diff --git a/pyam/core.py b/pyam/core.py
index c21a4b1..bddd8c6 100644
--- a/pyam/core.py
+++ b/pyam/core.py
@@ -1418,3 +1418,25 @@ def filter_by_meta(data, df, join_meta=False, **kwargs):
         data.index.name = None
 
     return data
+
+
+def compare(left, right, left_label='left', right_label='right',
+            drop_close=True, **kwargs):
+    """Compare the data in two IamDataFrames and return a pd.DataFrame
+
+    Parameters
+    ----------
+    left, right: IamDataFrames
+        the IamDataFrames to be compared
+    left_label, right_label: str, default `left`, `right`
+        column names of the returned dataframe
+    drop_close: bool, default True
+        remove all data where `left` and `right` are close
+    kwargs: passed to `np.isclose()`
+    """
+    ret = pd.concat({right_label: right.data.set_index(right._LONG_IDX),
+                     left_label: left.data.set_index(left._LONG_IDX)}, axis=1)
+    ret.columns = ret.columns.droplevel(1)
+    if drop_close:
+        ret = ret[~np.isclose(ret[left_label], ret[right_label], **kwargs)]
+    return ret[[right_label, left_label]]


Test patch:
diff --git a/tests/test_feature_compare.py b/tests/test_feature_compare.py
new file mode 100644
index 0000000..d6074a9
--- /dev/null
+++ b/tests/test_feature_compare.py
@@ -0,0 +1,29 @@
+import copy
+import numpy as np
+import pandas as pd
+from pyam import compare, IAMC_IDX
+
+
+def test_compare(meta_df):
+    clone = copy.deepcopy(meta_df)
+    clone.data.iloc[0, clone.data.columns.get_loc('value')] = 2
+    clone.rename({'variable': {'Primary Energy|Coal': 'Primary Energy|Gas'}},
+                 inplace=True)
+
+    obs = compare(meta_df, clone, right_label='meta_df', left_label='clone')
+
+    exp = pd.DataFrame([
+        ['Primary Energy', 'EJ/y', 2005, 2, 1],
+        ['Primary Energy|Coal', 'EJ/y', 2005, np.nan, 0.5],
+        ['Primary Energy|Coal', 'EJ/y', 2010, np.nan, 3],
+        ['Primary Energy|Gas', 'EJ/y', 2005, 0.5, np.nan],
+        ['Primary Energy|Gas', 'EJ/y', 2010, 3, np.nan],
+    ],
+        columns=['variable', 'unit', 'year', 'meta_df', 'clone'],
+    )
+    exp['model'] = 'a_model'
+    exp['scenario'] = 'a_scenario'
+    exp['region'] = 'World'
+    exp = exp.set_index(IAMC_IDX + ['year'])
+
+    pd.testing.assert_frame_equal(obs, exp)