Repository: sdv-dev/SDMetrics

Issue:
Update Referential Integrity metric to support NaNs in child column 
### Problem Description
- For the Referential Integrity metric, we currently do the following:
  - If the foreign key in the real data has any NaN values, those are dropped in the foreign key in the synthetic data.
  - We also verify that all foreign keys have valid parent.
- To support NaNs values in foreign keys, we need to update this logic. 

### Expected behavior
- Update `ReferentialIntegrity.compute_breakdown()` to do the following:
  - If the real data has NaN values, and the synthetic data has NaN values in the FK, then it is okay.
  - If the real data does not have NaN values, but the synthetic data has NaN values in the FK, this will result in a lower score. 
    - The score can be lowered by the number of extra NaNs present in the synthetic data.
- Update test_referential_interity.py
- Update documentation

### Additional context
- Function to update:
https://github.com/sdv-dev/SDMetrics/blob/51f3ac2904c065462097c80f4387b973657ad6d9/sdmetrics/column_pairs/statistical/referential_integrity.py#L36

Discussion:
sdv-team: Task linked: [CU-86b0uyzmn SDMetrics - Update ReferentialIntegrity to support NaNs in child column  #587](https://app.clickup.com/t/86b0uyzmn)

Patch:
diff --git a/sdmetrics/reports/single_table/_properties/column_pair_trends.py b/sdmetrics/reports/single_table/_properties/column_pair_trends.py
index 3286991..a30536a 100644
--- a/sdmetrics/reports/single_table/_properties/column_pair_trends.py
+++ b/sdmetrics/reports/single_table/_properties/column_pair_trends.py
@@ -123,7 +123,7 @@ class ColumnPairTrends(BaseSingleTableProperty):
                     column_name, processed_data[column_name]
                 )
 
-        return processed_data, pd.DataFrame(discretized_dict)
+        return processed_data, pd.DataFrame(discretized_dict, index=processed_data.index)
 
     def _get_columns_data_and_metric(
         self,


Test patch:
diff --git a/tests/integration/reports/single_table/_properties/test_column_pair_trends.py b/tests/integration/reports/single_table/_properties/test_column_pair_trends.py
index b0fed1f..3f34ed0 100644
--- a/tests/integration/reports/single_table/_properties/test_column_pair_trends.py
+++ b/tests/integration/reports/single_table/_properties/test_column_pair_trends.py
@@ -20,8 +20,8 @@ class TestColumnPairTrends:
         }
 
         # Run
-        column_shape_property = ColumnPairTrends()
-        score = column_shape_property.get_score(real_data, synthetic_data, metadata)
+        column_pair_trends = ColumnPairTrends()
+        score = column_pair_trends.get_score(real_data, synthetic_data, metadata)
 
         # Assert
         expected_details_dict = {
@@ -61,7 +61,7 @@ class TestColumnPairTrends:
             'Synthetic Correlation': [-0.11506297326956302, np.nan, np.nan, np.nan, np.nan, np.nan],
         }
         expected_details = pd.DataFrame(expected_details_dict)
-        pd.testing.assert_frame_equal(column_shape_property.details, expected_details)
+        pd.testing.assert_frame_equal(column_pair_trends.details, expected_details)
         assert score == 0.8050699533533958
 
     def test_get_score_warnings(self, recwarn):
@@ -77,7 +77,7 @@ class TestColumnPairTrends:
         real_data['second_perc'].iloc[2] = 'a'
 
         # Run
-        column_shape_property = ColumnPairTrends()
+        column_pair_trends = ColumnPairTrends()
 
         exp_message_1 = "ValueError: could not convert string to float: 'a'"
 
@@ -85,10 +85,10 @@ class TestColumnPairTrends:
 
         exp_error_serie = pd.Series([exp_message_1, None, None, exp_message_2, exp_message_2, None])
 
-        score = column_shape_property.get_score(real_data, synthetic_data, metadata)
+        score = column_pair_trends.get_score(real_data, synthetic_data, metadata)
 
         # Assert
-        details = column_shape_property.details
+        details = column_pair_trends.details
         pd.testing.assert_series_equal(details['Error'], exp_error_serie, check_names=False)
         assert score == 0.7751937984496124
 
@@ -103,8 +103,8 @@ class TestColumnPairTrends:
         }
 
         # Run
-        column_shape_property = ColumnPairTrends()
-        score = column_shape_property.get_score(real_data, synthetic_data, metadata)
+        column_pair_trends = ColumnPairTrends()
+        score = column_pair_trends.get_score(real_data, synthetic_data, metadata)
 
         # Assert
         expected_details_dict = {
@@ -137,5 +137,21 @@ class TestColumnPairTrends:
             'Synthetic Correlation': [np.nan] * 6,
         }
         expected_details = pd.DataFrame(expected_details_dict)
-        pd.testing.assert_frame_equal(column_shape_property.details, expected_details)
+        pd.testing.assert_frame_equal(column_pair_trends.details, expected_details)
         assert score == 0.8930232558139535
+
+    def test_with_different_indexes(self):
+        """Test the property when the real and synthetic data only differ by their indexes."""
+        # Setup
+        real_data = pd.DataFrame({'A': [1, 2, 3], 'B': ['a', 'b', 'a']}, index=[0, 1, 2])
+
+        synthetic_data = pd.DataFrame({'A': [1, 2, 3], 'B': ['a', 'b', 'a']}, index=[0, 4, 2])
+
+        metadata = {'columns': {'A': {'sdtype': 'numerical'}, 'B': {'sdtype': 'categorical'}}}
+
+        # Run
+        column_pair_trends = ColumnPairTrends()
+        score = column_pair_trends.get_score(real_data, synthetic_data, metadata)
+
+        # Assert
+        assert score == 1.0
diff --git a/tests/unit/column_pairs/statistical/test_referential_integrity.py b/tests/unit/column_pairs/statistical/test_referential_integrity.py
index 6214542..a68cc77 100644
--- a/tests/unit/column_pairs/statistical/test_referential_integrity.py
+++ b/tests/unit/column_pairs/statistical/test_referential_integrity.py
@@ -68,32 +68,56 @@ class TestReferentialIntegrity:
         assert result == 0.6
 
     def test_compute_with_nan_foreign_keys_real_data(self):
-        """Test the ``compute`` method with NaN foreign keys inside the real data."""
+        """Test the ``compute`` method with NaN foreign keys inside the real data.
+
+        Here, the score should be 1.0, whether or not the synthetic data have NaN values
+        values, as the real data have null foreign keys.
+        """
         # Setup
         parent_keys = pd.Series(['a', 'b', 'c'])
-        foreign_keys = pd.Series(['a', 'a', 'b', 'c', np.nan])
+        real_fk = pd.Series(['a', 'a', 'b', 'c', np.nan])
+        synthetic_fk = pd.Series(['a', 'a', 'b', 'c', 'a'])
+        synthetic_fk_with_nan = pd.Series(['a', 'a', 'b', 'c', np.nan])
         metric = ReferentialIntegrity()
 
         # Run
         result = metric.compute(
-            real_data=(parent_keys, foreign_keys), synthetic_data=(parent_keys, foreign_keys)
+            real_data=(parent_keys, real_fk), synthetic_data=(parent_keys, synthetic_fk)
+        )
+        result_with_nan = metric.compute(
+            real_data=(parent_keys, real_fk), synthetic_data=(parent_keys, synthetic_fk_with_nan)
         )
 
         # Assert
         assert result == 1.0
+        assert result_with_nan == 1.0
 
     def test_compute_with_nan_foreign_keys_only_synthetic_data(self):
-        """Test the ``compute`` method with NaN foreign keys inside the synthetic data."""
+        """Test the ``compute`` method with NaN foreign keys inside the synthetic data.
+
+        Here, the real data have no null foreign keys, so the score should decrease as
+        the number of NaN values in the synthetic data increases.
+        """
         # Setup
         parent_keys = pd.Series(['a', 'b', 'c'])
-        foreign_keys = pd.Series(['a', 'a', 'b', 'c', 'a'])
-        synth_foreign_keys = pd.Series(['a', 'a', 'b', 'c', np.nan])
+        real_fk = pd.Series(['a', 'a', 'b', 'c', 'a'])
+        synth_fk_0_nan = pd.Series(['a', 'a', 'b', 'c'])
+        synth_fk_1_nan = pd.Series(['a', 'a', 'b', 'c', np.nan])
+        synth_fk_2_nan = pd.Series(['a', 'a', 'b', 'c', np.nan, np.nan])
         metric = ReferentialIntegrity()
 
         # Run
-        result = metric.compute(
-            real_data=(parent_keys, foreign_keys), synthetic_data=(parent_keys, synth_foreign_keys)
+        result_0 = metric.compute(
+            real_data=(parent_keys, real_fk), synthetic_data=(parent_keys, synth_fk_0_nan)
+        )
+        result_1 = metric.compute(
+            real_data=(parent_keys, real_fk), synthetic_data=(parent_keys, synth_fk_1_nan)
+        )
+        result_2 = metric.compute(
+            real_data=(parent_keys, real_fk), synthetic_data=(parent_keys, synth_fk_2_nan)
         )
 
         # Assert
-        assert result == 0.8
+        assert result_0 == 1.0
+        assert result_1 == 0.8
+        assert result_2 == 2 / 3
diff --git a/tests/unit/reports/single_table/_properties/test_column_pair_trends.py b/tests/unit/reports/single_table/_properties/test_column_pair_trends.py
index 861e1f7..8384ab8 100644
--- a/tests/unit/reports/single_table/_properties/test_column_pair_trends.py
+++ b/tests/unit/reports/single_table/_properties/test_column_pair_trends.py
@@ -66,12 +66,15 @@ class TestColumnPairTrends:
     def test__get_processed_data(self):
         """Test the ``_get_processed_data`` method."""
         # Setup
-        data = pd.DataFrame({
-            'col1': [1, 2, 3],
-            'col2': [False, True, True],
-            'col3': ['a', 'b', 'c'],
-            'col4': pd.to_datetime(['2020-01-01', '2020-01-02', '2020-01-03']),
-        })
+        data = pd.DataFrame(
+            {
+                'col1': [1, 2, 3],
+                'col2': [False, True, True],
+                'col3': ['a', 'b', 'c'],
+                'col4': pd.to_datetime(['2020-01-01', '2020-01-02', '2020-01-03']),
+            },
+            index=[4, 5, 6],
+        )
         metadata = {
             'columns': {
                 'col1': {'sdtype': 'numerical'},
@@ -89,17 +92,23 @@ class TestColumnPairTrends:
         expected_datetime = pd.to_numeric(
             pd.to_datetime(['2020-01-01', '2020-01-02', '2020-01-03'])
         )
-        expected_processed_data = pd.DataFrame({
-            'col1': [1, 2, 3],
-            'col2': [False, True, True],
-            'col3': ['a', 'b', 'c'],
-            'col4': expected_datetime,
-        })
+        expected_processed_data = pd.DataFrame(
+            {
+                'col1': [1, 2, 3],
+                'col2': [False, True, True],
+                'col3': ['a', 'b', 'c'],
+                'col4': expected_datetime,
+            },
+            index=[4, 5, 6],
+        )
 
-        expected_discrete_data = pd.DataFrame({
-            'col1': [1, 6, 11],
-            'col4': [1, 6, 11],
-        })
+        expected_discrete_data = pd.DataFrame(
+            {
+                'col1': [1, 6, 11],
+                'col4': [1, 6, 11],
+            },
+            index=[4, 5, 6],
+        )
 
         pd.testing.assert_frame_equal(processed_data, expected_processed_data)
         pd.testing.assert_frame_equal(discrete_data, expected_discrete_data)