From 807933dc1cc506f46e0fb674116fbc8894fa366b Mon Sep 17 00:00:00 2001 From: Pierre Michielin Date: Fri, 31 Jul 2026 15:55:02 +0200 Subject: [PATCH] feat: unify display of missing values across plots and webapp Null feature values were rendered inconsistently (raw "nan", "None", blank cells) depending on the component. They are now displayed as "missing" everywhere, via a shared format_missing_value helper: - contribution plot: extend NaN handling to object/category columns (missing modality + "x" marker) and fix violin point hover customdata - local plot: bar labels and hover text, incl. grouped features - compare plot: hover text - cluster plot: point hover text in classification and regression branches; also guard all-null clusters (mode()[0] IndexError, NaN mean/std formatting) and allow null as top modality - webapp dataset table: cells and tooltips through a shared get_datatable_data_and_tooltips helper; identity card label Co-Authored-By: Claude Fable 5 --- shapash/explainer/smart_plotter.py | 64 +++++++++++------- shapash/plots/plot_bar_chart.py | 5 +- shapash/plots/plot_contribution.py | 30 +++++++-- shapash/plots/plot_line_comparison.py | 4 +- shapash/utils/utils.py | 28 ++++++++ shapash/webapp/smart_app.py | 16 ++--- shapash/webapp/utils/callbacks.py | 3 +- shapash/webapp/utils/utils.py | 29 ++++++++ .../explainer/test_smart_plotter.py | 66 +++++++++++++++++++ tests/unit_tests/utils/test_utils.py | 33 ++++++++++ tests/unit_tests/webapp/utils/test_utils.py | 36 +++++++++- 11 files changed, 268 insertions(+), 46 deletions(-) diff --git a/shapash/explainer/smart_plotter.py b/shapash/explainer/smart_plotter.py index 6a8e22a8..b1049227 100644 --- a/shapash/explainer/smart_plotter.py +++ b/shapash/explainer/smart_plotter.py @@ -38,6 +38,7 @@ adjust_title_height, compute_digit_number, compute_sorted_variables_interactions_list_indices, + format_missing_value, maximum_difference_sort_value, top_contributors, truncate_str, @@ -2345,7 +2346,7 @@ def clustering_by_explainability_plot( for idx, row in df_pred.iterrows(): text = f"Id: {idx}
" if el not in ["predictions", "targets", "errors"]: - text += f"{el}: {row[el]}
" + text += f"{el}: {format_missing_value(row[el])}
" text += f"Predicted Value: {row['proba_values']:.{self._round_digit}f}
" if "error" in df_pred.columns: text += f"Error: {row['error']:.{self._round_digit}f}
" @@ -2360,19 +2361,30 @@ def clustering_by_explainability_plot( if el not in ["predictions", "targets", "errors"]: is_num = is_numeric_dtype(df_pred[el]) and not is_bool_dtype(df_pred[el]) n_unique = df_pred[el].nunique(dropna=True) + cluster_values = df_pred.loc[df_pred["cluster"] == c, el] if is_num and n_unique > 5: - mean_el = df_pred.loc[df_pred["cluster"] == c, el].mean() - std_el = df_pred.loc[df_pred["cluster"] == c, el].std() - hv_text_cluster += f"
{el} mean: {mean_el:.{compute_digit_number(mean_el, 3)}f}" - hv_text_cluster += f"
{el} std: {std_el:.{compute_digit_number(std_el, 3)}f}" + mean_el = cluster_values.mean() + std_el = cluster_values.std() + if pd.isna(mean_el) or pd.isna(std_el): + hv_text_cluster += f"
{el} mean: {format_missing_value(mean_el)}" + hv_text_cluster += f"
{el} std: {format_missing_value(std_el)}" + else: + hv_text_cluster += ( + f"
{el} mean: {mean_el:.{compute_digit_number(mean_el, 3)}f}" + ) + hv_text_cluster += f"
{el} std: {std_el:.{compute_digit_number(std_el, 3)}f}" else: - top_element = df_pred.loc[df_pred["cluster"] == c, el].mode()[0] - top_element_percentage = ( - np.sum(df_pred.loc[df_pred["cluster"] == c, el] == top_element) - / df_pred.loc[df_pred["cluster"] == c, el].size - * 100 + # dropna=False so that null values can be reported as the top modality + top_element = cluster_values.mode(dropna=False).iloc[0] + if pd.isna(top_element): + top_element_count = cluster_values.isna().sum() + else: + top_element_count = np.sum(cluster_values == top_element) + top_element_percentage = top_element_count / cluster_values.size * 100 + hv_text_cluster += ( + f"
{el} top: {format_missing_value(top_element)}" + f" ({top_element_percentage:.1f}%)" ) - hv_text_cluster += f"
{el} top: {top_element} ({top_element_percentage:.1f}%)" mean_predicted_value = df_pred.loc[df_pred["cluster"] == c, "proba_values"].mean() hv_text_cluster += f"
Mean predicted value: {mean_predicted_value:.{compute_digit_number(mean_predicted_value, 3)}f}" if "error" in df_pred.columns: @@ -2490,7 +2502,7 @@ def clustering_by_explainability_plot( for idx, row in df_pred.iterrows(): text = f"Id: {idx}
" if el not in ["predictions", "targets", "errors"]: - text += f"{el}: {row[el]}
" + text += f"{el}: {format_missing_value(row[el])}
" text += f"Predicted Value: {row['predict_value']:.{self._round_digit}f}
" if "error" in df_pred.columns: text += f"Error: {row['error']:.{compute_digit_number(row['error'])}f}
" @@ -2504,19 +2516,27 @@ def clustering_by_explainability_plot( if el not in ["predictions", "targets", "errors"]: is_num = is_numeric_dtype(df_pred[el]) and not is_bool_dtype(df_pred[el]) n_unique = df_pred[el].nunique(dropna=True) + cluster_values = df_pred.loc[df_pred["cluster"] == c, el] if is_num and n_unique > 5: - mean_el = df_pred.loc[df_pred["cluster"] == c, el].mean() - std_el = df_pred.loc[df_pred["cluster"] == c, el].std() - hv_text_cluster += f"
{el} mean: {mean_el:.{compute_digit_number(mean_el, 3)}f}" - hv_text_cluster += f"
{el} std: {std_el:.{compute_digit_number(std_el, 3)}f}" + mean_el = cluster_values.mean() + std_el = cluster_values.std() + if pd.isna(mean_el) or pd.isna(std_el): + hv_text_cluster += f"
{el} mean: {format_missing_value(mean_el)}" + hv_text_cluster += f"
{el} std: {format_missing_value(std_el)}" + else: + hv_text_cluster += f"
{el} mean: {mean_el:.{compute_digit_number(mean_el, 3)}f}" + hv_text_cluster += f"
{el} std: {std_el:.{compute_digit_number(std_el, 3)}f}" else: - top_element = df_pred.loc[df_pred["cluster"] == c, el].mode()[0] - top_element_percentage = ( - np.sum(df_pred.loc[df_pred["cluster"] == c, el] == top_element) - / df_pred.loc[df_pred["cluster"] == c, el].size - * 100 + # dropna=False so that null values can be reported as the top modality + top_element = cluster_values.mode(dropna=False).iloc[0] + if pd.isna(top_element): + top_element_count = cluster_values.isna().sum() + else: + top_element_count = np.sum(cluster_values == top_element) + top_element_percentage = top_element_count / cluster_values.size * 100 + hv_text_cluster += ( + f"
{el} top: {format_missing_value(top_element)} ({top_element_percentage:.1f}%)" ) - hv_text_cluster += f"
{el} top: {top_element} ({top_element_percentage:.1f}%)" mean_predicted_value = df_pred.loc[df_pred["cluster"] == c, "predict_value"].mean() hv_text_cluster += f"
Mean predicted value: {mean_predicted_value:.{compute_digit_number(mean_predicted_value, 3)}f}" if "error" in df_pred.columns: diff --git a/shapash/plots/plot_bar_chart.py b/shapash/plots/plot_bar_chart.py index cbb66b6d..9cd95f87 100644 --- a/shapash/plots/plot_bar_chart.py +++ b/shapash/plots/plot_bar_chart.py @@ -1,7 +1,7 @@ from plotly import graph_objs as go from plotly.offline import plot -from shapash.utils.utils import add_line_break, adjust_title_height, truncate_str +from shapash.utils.utils import add_line_break, adjust_title_height, format_missing_value, truncate_str def plot_bar_chart( @@ -103,6 +103,7 @@ def plot_bar_chart( bars = [] for num, expl in enumerate(zip(var_dict, x_val, contrib, strict=False)): feat_name, x_val_el, contrib_value = expl + x_val_el = format_missing_value(x_val_el) is_grouped = False if x_val_el == "": ylabel = f"{feat_name}" @@ -116,7 +117,7 @@ def plot_bar_chart( feat_groups_values = x_init[features_groups[group_name]].loc[index_value[0]] hoverlabel = "
".join( [ - f"{add_line_break(features_dict.get(f_name, f_name), 40, maxlen=120)} :{add_line_break(f_value, 40, maxlen=160)}" + f"{add_line_break(features_dict.get(f_name, f_name), 40, maxlen=120)} :{add_line_break(format_missing_value(f_value), 40, maxlen=160)}" for f_name, f_value in feat_groups_values.to_dict().items() ] ) diff --git a/shapash/plots/plot_contribution.py b/shapash/plots/plot_contribution.py index 3e25c998..5bae989a 100644 --- a/shapash/plots/plot_contribution.py +++ b/shapash/plots/plot_contribution.py @@ -7,7 +7,13 @@ from plotly.subplots import make_subplots from sklearn.neighbors import KernelDensity -from shapash.utils.utils import add_line_break, adjust_title_height, truncate_str +from shapash.utils.utils import ( + MISSING_VALUE_DISPLAY, + add_line_break, + adjust_title_height, + format_missing_value, + truncate_str, +) from shapash.webapp.utils.utils import round_to_k NAN_PLACEHOLDER_K = 0.2 @@ -166,7 +172,8 @@ def plot_scatter( fig.add_trace(density_plot) nan_mask_arr = pd.isna(feature_values.iloc[:, 0]).to_numpy() - has_nan_numeric = bool(nan_mask_arr.any()) and feature_values.iloc[:, 0].dtype.kind in "biufc" + has_nan = bool(nan_mask_arr.any()) + has_nan_numeric = has_nan and feature_values.iloc[:, 0].dtype.kind in "biufc" marker = None if has_nan_numeric: non_nan_arr = feature_values_array[~nan_mask_arr].astype(float) @@ -178,6 +185,11 @@ def plot_scatter( nan_x = 0.0 feature_values_array = np.where(nan_mask_arr, nan_x, feature_values_array) marker = {"symbol": np.where(nan_mask_arr, "x", "circle").tolist()} + elif has_nan: + # non-numeric columns: display null values as an explicit "missing" modality + feature_values_array = feature_values_array.astype(object).copy() + feature_values_array[nan_mask_arr] = MISSING_VALUE_DISPLAY + marker = {"symbol": np.where(nan_mask_arr, "x", "circle").tolist()} fig.add_scatter( x=feature_values_array, @@ -199,9 +211,9 @@ def plot_scatter( # The values are used in the hovertext and the indexes are used for # the interactions between the graphics. customdata_values = feature_values_array - if has_nan_numeric: + if has_nan: customdata_values = feature_values_array.astype(object).copy() - customdata_values[nan_mask_arr] = "missing" + customdata_values[nan_mask_arr] = MISSING_VALUE_DISPLAY customdata = np.stack((customdata_values, feature_values.index.values), axis=-1) fig.update_traces(customdata=customdata, hovertemplate=hovertemplate) @@ -325,7 +337,7 @@ def plot_violin( for i, c in enumerate(xs): if pd.isna(c): is_c = feature_values.iloc[:, 0].isna() - c_label = "missing" + c_label = MISSING_VALUE_DISPLAY else: is_c = feature_values.iloc[:, 0] == c c_label = c @@ -440,7 +452,7 @@ def plot_violin( ) # To change ticktext - xs_labels = ["missing" if pd.isna(x) else x for x in xs] + xs_labels = [format_missing_value(x) for x in xs] _update_xaxis_labels(fig, xs_labels, zoom) _update_contributions_fig( @@ -754,8 +766,12 @@ def _add_violin_and_scatter( x = _create_jittered_points(x, percentage_series, side=side) if colorpoints is not None: colorpoints_selected = colorpoints.loc[feature_cond].values.flatten() + # display null values as "missing" in the hover text + point_values = np.array( + [format_missing_value(v) for v in feature_values.loc[feature_cond].values.flatten()], dtype=object + ) customdata = np.stack( - (feature_values.loc[feature_cond].values.flatten(), contributions.loc[feature_cond].index.values), + (point_values, contributions.loc[feature_cond].index.values), axis=-1, ) marker = None diff --git a/shapash/plots/plot_line_comparison.py b/shapash/plots/plot_line_comparison.py index 676461d5..0bb0b0e8 100644 --- a/shapash/plots/plot_line_comparison.py +++ b/shapash/plots/plot_line_comparison.py @@ -3,7 +3,7 @@ from plotly import graph_objs as go from plotly.offline import plot -from shapash.utils.utils import add_line_break, adjust_title_height, truncate_str +from shapash.utils.utils import add_line_break, adjust_title_height, format_missing_value, truncate_str def plot_line_comparison( @@ -108,7 +108,7 @@ def plot_line_comparison( f"Id: {add_line_break(id_i, 40, 160)}" + f"
{add_line_break(feat, 40, 160)}
" + f"Contribution: {contrib[i]:.4f}
Value: " - + str(add_line_break(pred_x_val, 40, 160)) + + str(add_line_break(format_missing_value(pred_x_val), 40, 160)) ) lines.append( diff --git a/shapash/utils/utils.py b/shapash/utils/utils.py index c9ea55f2..2f51c5f8 100644 --- a/shapash/utils/utils.py +++ b/shapash/utils/utils.py @@ -199,6 +199,34 @@ def truncate_str(text, maxlen=40): return text +MISSING_VALUE_DISPLAY = "missing" + + +def format_missing_value(value, missing_display=MISSING_VALUE_DISPLAY): + """ + return a unified display value for null entries + + Parameters + ---------- + value : any + value to display, can be null (NaN, None, NaT, pd.NA) + missing_display : str + text displayed in place of null values + + Returns + ------- + any + missing_display if the value is null, the original value otherwise + """ + try: + if pd.isna(value): + return missing_display + except (TypeError, ValueError): + # non-scalar values (list, array, ...) are kept unchanged + pass + return value + + def compute_digit_number(value, significant_digits: int = 4): """ return int, number of digits to display diff --git a/shapash/webapp/smart_app.py b/shapash/webapp/smart_app.py index bb062a94..d3ce6a7f 100644 --- a/shapash/webapp/smart_app.py +++ b/shapash/webapp/smart_app.py @@ -47,7 +47,7 @@ ) from shapash.webapp.utils.explanations import Explanations from shapash.webapp.utils.MyGraph import MyGraph -from shapash.webapp.utils.utils import check_row, get_index_type, round_to_k +from shapash.webapp.utils.utils import check_row, get_datatable_data_and_tooltips, get_index_type, round_to_k def _create_input_modal(component_id, label, tooltip): @@ -431,13 +431,11 @@ def init_components(self): self.adjust_menu() + table_data, table_tooltip_data = get_datatable_data_and_tooltips(self.round_dataframe, self.dataframe) self.components["table"]["dataset"] = dash_table.DataTable( id="dataset", - data=self.round_dataframe.to_dict("records"), - tooltip_data=[ - {column: {"value": str(value), "type": "text"} for column, value in row.items()} - for row in self.dataframe.to_dict("index").values() - ], + data=table_data, + tooltip_data=table_tooltip_data, tooltip_duration=2000, columns=[{"name": i, "id": i} for i in self.dataframe.columns], tooltip_header={ @@ -2108,11 +2106,7 @@ def update_datatable( df = self.round_dataframe else: raise dash.exceptions.PreventUpdate - data = df.to_dict("records") - tooltip_data = [ - {column: {"value": str(value), "type": "text"} for column, value in row.items()} - for row in df.to_dict("index").values() - ] + data, tooltip_data = get_datatable_data_and_tooltips(df) return ( data, tooltip_data, diff --git a/shapash/webapp/utils/callbacks.py b/shapash/webapp/utils/callbacks.py index 3ff66d3a..0ad1108f 100644 --- a/shapash/webapp/utils/callbacks.py +++ b/shapash/webapp/utils/callbacks.py @@ -11,6 +11,7 @@ from dash.exceptions import PreventUpdate from plotly.graph_objs import Figure +from shapash.utils.utils import format_missing_value from shapash.webapp.utils.MyGraph import MyGraph @@ -506,7 +507,7 @@ def create_id_card_layout(selected_data: pd.DataFrame, additional_features_dict: dbc.Row( [ dbc.Col(dbc.Label(row["feature_name"]), width=3, style=label_style), - dbc.Col(dbc.Label(row["feature_value"]), width=5, className="id_card_solid"), + dbc.Col(dbc.Label(format_missing_value(row["feature_value"])), width=5, className="id_card_solid"), dbc.Col(width=1), ( dbc.Col( diff --git a/shapash/webapp/utils/utils.py b/shapash/webapp/utils/utils.py index 27675e3a..8488728e 100644 --- a/shapash/webapp/utils/utils.py +++ b/shapash/webapp/utils/utils.py @@ -1,6 +1,35 @@ import pandas as pd from pandas.api.types import is_any_real_numeric_dtype +from shapash.utils.utils import format_missing_value + + +def get_datatable_data_and_tooltips(data_df, tooltip_df=None): + """ + Build the data records and tooltips of the dataset DataTable, + with a unified display of missing values. + + Parameters + ---------- + data_df : pd.DataFrame + Dataframe used for the cells of the datatable + tooltip_df : pd.DataFrame (optional) + Dataframe used for the tooltips of the datatable, data_df if not provided + + Returns + ------- + tuple + data records and tooltip_data of the datatable + """ + if tooltip_df is None: + tooltip_df = data_df + data = [{col: format_missing_value(val) for col, val in row.items()} for row in data_df.to_dict("records")] + tooltip_data = [ + {col: {"value": str(format_missing_value(val)), "type": "text"} for col, val in row.items()} + for row in tooltip_df.to_dict("records") + ] + return data, tooltip_data + def round_to_k(x, k): """ diff --git a/tests/unit_tests/explainer/test_smart_plotter.py b/tests/unit_tests/explainer/test_smart_plotter.py index b6c22eb6..acbb5d89 100644 --- a/tests/unit_tests/explainer/test_smart_plotter.py +++ b/tests/unit_tests/explainer/test_smart_plotter.py @@ -19,6 +19,7 @@ from shapash.explainer.multi_decorator import MultiDecorator from shapash.explainer.smart_state import SmartState from shapash.plots.plot_bar_chart import plot_bar_chart +from shapash.plots.plot_contribution import plot_scatter from shapash.plots.plot_evaluation_metrics import plot_confusion_matrix from shapash.plots.plot_feature_importance import _plot_features_import from shapash.plots.plot_line_comparison import plot_line_comparison @@ -1190,6 +1191,71 @@ def test_contribution_plot_nan_numeric_violin(self): ticktext = list(output.layout.xaxis.ticktext) if output.layout.xaxis.ticktext else [] assert "missing" in ticktext + # the hover text of the scatter points of the "missing" modality must also show "missing" + missing_scatters = [ + t for t in output.data if t.type == "scatter" and t.mode == "markers" and t.name == "missing" + ] + assert len(missing_scatters) > 0 + for trace in missing_scatters: + assert all(row[0] == "missing" for row in trace.customdata) + + def test_contribution_plot_nan_object_scatter(self): + """ + Object feature with null values must render on the scatter contribution plot + as an explicit "missing" modality with an "x" marker symbol, with "missing" + surfaced in the hover customdata. + Regression test for https://github.com/MAIF/shapash/issues/721 + """ + n_val, n_nan = 8, 2 + feature_values = pd.DataFrame( + {"str_feat": ["a", "b", None, "c", "b", None, "d", "e"]}, index=list(range(n_val)) + ) + contributions = pd.DataFrame({"str_feat": [0.1, -0.2, 0.3, 0.4, -0.1, 0.2, 0.15, -0.3]}) + output = plot_scatter( + feature_values, + contributions, + "str_feat", + "regression", + self.smart_explainer.plot._style_dict, + ) + + marker_traces = [t for t in output.data if t.type == "scatter" and t.mode == "markers"] + assert len(marker_traces) == 1 + trace = marker_traces[0] + + x_arr = list(trace.x) + assert len(x_arr) == n_val + assert all(isinstance(x, str) for x in x_arr) + assert x_arr.count("missing") == n_nan + + symbols = list(trace.marker.symbol) + assert symbols.count("x") == n_nan + assert symbols.count("circle") == n_val - n_nan + + customdata_col0 = [row[0] for row in trace.customdata] + nan_customdata = [v for v, s in zip(customdata_col0, symbols) if s == "x"] + assert all(v == "missing" for v in nan_customdata) + + def test_plot_bar_chart_nan_display(self): + """ + Null feature values must be displayed as "missing" in the local plot + y-axis labels and hover text. + Regression test for https://github.com/MAIF/shapash/issues/721 + """ + var_dict = ["X1", "X2"] + x_val = [np.nan, "PhD"] + contributions = [-3.4, 0.78] + self.smart_explainer._case = "regression" + fig_output = plot_bar_chart("ind", var_dict, x_val, contributions, self.smart_explainer.plot._style_dict) + + ylabels = [bar.y[0] for bar in fig_output.data] + assert "X1 :
missing" in ylabels + assert not any("nan" in str(label) for label in ylabels) + + hoverlabels = [bar.customdata[0] for bar in fig_output.data] + assert any("missing" in label for label in hoverlabels) + assert not any("nan" in str(label) for label in hoverlabels) + def test_plot_features_import_1(self): """ Unit test plot features import 1 diff --git a/tests/unit_tests/utils/test_utils.py b/tests/unit_tests/utils/test_utils.py index 3b0d70da..62122444 100644 --- a/tests/unit_tests/utils/test_utils.py +++ b/tests/unit_tests/utils/test_utils.py @@ -4,10 +4,12 @@ import pandas as pd from shapash.utils.utils import ( + MISSING_VALUE_DISPLAY, add_line_break, compute_digit_number, compute_sorted_variables_interactions_list_indices, compute_top_correlations_features, + format_missing_value, inclusion, is_nested_list, maximum_difference_sort_value, @@ -160,3 +162,34 @@ def test_compute_top_correlations_features_2(self): list_features = compute_top_correlations_features(corr=corr, max_features=5) assert len(list_features) == 5 + + def test_format_missing_value_1(self): + """ + Test null values are unified to the missing display value + """ + assert format_missing_value(np.nan) == MISSING_VALUE_DISPLAY + assert format_missing_value(None) == MISSING_VALUE_DISPLAY + assert format_missing_value(pd.NA) == MISSING_VALUE_DISPLAY + assert format_missing_value(pd.NaT) == MISSING_VALUE_DISPLAY + + def test_format_missing_value_2(self): + """ + Test non-null values are kept unchanged + """ + assert format_missing_value("") == "" + assert format_missing_value(0) == 0 + assert format_missing_value(3.2) == 3.2 + assert format_missing_value("abc") == "abc" + + def test_format_missing_value_3(self): + """ + Test non-scalar values are kept unchanged + """ + value = [1, np.nan] + assert format_missing_value(value) is value + + def test_format_missing_value_4(self): + """ + Test custom missing display value + """ + assert format_missing_value(np.nan, missing_display="N/A") == "N/A" diff --git a/tests/unit_tests/webapp/utils/test_utils.py b/tests/unit_tests/webapp/utils/test_utils.py index 4f75d6a0..4b694c37 100644 --- a/tests/unit_tests/webapp/utils/test_utils.py +++ b/tests/unit_tests/webapp/utils/test_utils.py @@ -1,9 +1,43 @@ import unittest -from shapash.webapp.utils.utils import round_to_k +import numpy as np +import pandas as pd + +from shapash.webapp.utils.utils import get_datatable_data_and_tooltips, round_to_k class TestUtils(unittest.TestCase): + def test_get_datatable_data_and_tooltips_1(self): + """ + Null values must be displayed as "missing" in the datatable cells and tooltips + """ + df = pd.DataFrame({"num": [1.5, np.nan], "txt": ["a", None]}) + data, tooltip_data = get_datatable_data_and_tooltips(df) + + assert data[0] == {"num": 1.5, "txt": "a"} + assert data[1] == {"num": "missing", "txt": "missing"} + assert tooltip_data[0] == { + "num": {"value": "1.5", "type": "text"}, + "txt": {"value": "a", "type": "text"}, + } + assert tooltip_data[1] == { + "num": {"value": "missing", "type": "text"}, + "txt": {"value": "missing", "type": "text"}, + } + + def test_get_datatable_data_and_tooltips_2(self): + """ + Tooltips can be built from a different (unrounded) dataframe than the cells + """ + data_df = pd.DataFrame({"num": [1.5, np.nan]}) + tooltip_df = pd.DataFrame({"num": [1.54321, np.nan]}) + data, tooltip_data = get_datatable_data_and_tooltips(data_df, tooltip_df) + + assert data[0] == {"num": 1.5} + assert data[1] == {"num": "missing"} + assert tooltip_data[0] == {"num": {"value": "1.54321", "type": "text"}} + assert tooltip_data[1] == {"num": {"value": "missing", "type": "text"}} + def test_round_to_k_1(self): x = 123456789 expected_r_x = 123000000