From 229af5b00b72a5816928107313ebb10dbffe55e1 Mon Sep 17 00:00:00 2001 From: Maximilian Date: Fri, 5 Jan 2024 14:36:40 +0100 Subject: [PATCH 1/5] started work on the stacked bar plots for nSII --- shapiq/plot/_config.py | 23 +++++++-- shapiq/plot/n_sii_stacked_bar.py | 82 ++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 shapiq/plot/n_sii_stacked_bar.py diff --git a/shapiq/plot/_config.py b/shapiq/plot/_config.py index 9bf34ca5..05166b06 100644 --- a/shapiq/plot/_config.py +++ b/shapiq/plot/_config.py @@ -1,12 +1,27 @@ """This module contains the configuration for the shapiq visualizations.""" from colour import Color -RED = Color("#ff0d57") -BLUE = Color("#1e88e5") -NEUTRAL = Color("#ffffff") - __all__ = [ "RED", "BLUE", "NEUTRAL", + "COLORS_N_SII", +] + +RED = Color("#ff0d57") +BLUE = Color("#1e88e5") +NEUTRAL = Color("#ffffff") + +COLORS_N_SII = [ + "#D81B60", + "#FFB000", + "#1E88E5", + "#FE6100", + "#7F975F", + "#74ced2", + "#708090", + "#9966CC", + "#CCCCCC", + "#800080", ] +COLORS_N_SII = COLORS_N_SII * (100 + (len(COLORS_N_SII))) # repeat the colors list diff --git a/shapiq/plot/n_sii_stacked_bar.py b/shapiq/plot/n_sii_stacked_bar.py new file mode 100644 index 00000000..19a979c0 --- /dev/null +++ b/shapiq/plot/n_sii_stacked_bar.py @@ -0,0 +1,82 @@ +"""This module contains functions to plot the n_sii stacked bar charts.""" +__all__ = ["n_sii_stacked_bar_plot"] + +from copy import deepcopy +from typing import Union + +import numpy as np +from matplotlib import pyplot as plt +from matplotlib.patches import Patch + +from _config import COLORS_N_SII + + +def n_sii_stacked_bar_plot( + feature_names: Union[list, np.ndarray], + n_shapley_values_pos: dict, + n_shapley_values_neg: dict, + n_sii_order: int, +): + """Plot the n-SII values for a given instance. + + Args: + feature_names (list): The names of the features. + n_shapley_values_pos (dict): The positive n-SII values. + n_shapley_values_neg (dict): The negative n-SII values. + n_sii_order (int): The order of the n-SII values. + + Returns: + tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: A tuple containing the figure and + the axis of the plot. + """ + fig, axis = plt.subplots(figsize=(6, 4.15)) + + # transform data to make plotting easier + n_features = len(feature_names) + x = np.arange(n_features) + values_pos = np.array([values for order, values in n_shapley_values_pos.items()]) + values_neg = np.array([values for order, values in n_shapley_values_neg.items()]) + + # get helper variables for plotting the bars + min_max_values = [0, 0] # to set the y-axis limits after all bars are plotted + reference_pos = np.zeros(n_features) # to plot the bars on top of each other + reference_neg = deepcopy(values_neg[0]) # to plot the bars below of each other + + # plot the bar segments + for order in range(len(values_pos)): + axis.bar(x, height=values_pos[order], bottom=reference_pos, color=COLORS_N_SII[order]) + axis.bar(x, height=abs(values_neg[order]), bottom=reference_neg, color=COLORS_N_SII[order]) + axis.axhline(y=0, color="black", linestyle="solid", linewidth=0.5) + reference_pos += values_pos[order] + try: + reference_neg += values_neg[order + 1] + except KeyError: + pass + min_max_values[0] = min(min_max_values[0], min(reference_neg)) + min_max_values[1] = max(min_max_values[1], max(reference_pos)) + + # add a legend to the plots + legend_elements = [] + for order in range(n_sii_order): + legend_elements.append( + Patch(facecolor=COLORS_N_SII[order], edgecolor="black", label=f"Order {order + 1}") + ) + axis.legend(handles=legend_elements, loc="upper center", ncol=min(n_sii_order, 4)) + + x_ticks_labels = [feature for feature in feature_names] # might be unnecessary + axis.set_xticks(x) + axis.set_xticklabels(x_ticks_labels, rotation=45, ha="right") + + axis.set_xlim(-0.5, n_features - 0.5) + axis.set_ylim( + min_max_values[0] - abs(min_max_values[1] - min_max_values[0]) * 0.02, + min_max_values[1] + abs(min_max_values[1] - min_max_values[0]) * 0.3, + ) + + axis.set_ylabel("n-SII values") + axis.set_xlabel("features") + axis.set_title(f"n-SII values up to order ${n_sii_order}$") + + plt.tight_layout() + + return fig, axis From 848f7b0c0544573db62ffccc1de5bce9d13bee90 Mon Sep 17 00:00:00 2001 From: Maximilian Date: Mon, 5 Feb 2024 18:50:47 +0100 Subject: [PATCH 2/5] changed docs to network plot --- shapiq/plot/network.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/shapiq/plot/network.py b/shapiq/plot/network.py index 0f660533..080215d6 100644 --- a/shapiq/plot/network.py +++ b/shapiq/plot/network.py @@ -32,7 +32,18 @@ def _add_weight_to_edges_in_graph( n_features: int, feature_names: list[str], ) -> None: - """Adds the weights to the edges in the graph.""" + """Adds the weights to the edges in the graph. + + Args: + graph (nx.Graph): The graph to add the weights to. + first_order_values (np.ndarray): The first order n-SII values. + second_order_values (np.ndarray): The second order n-SII values. + n_features (int): The number of features. + feature_names (list[str]): The names of the features. + + Returns: + None + """ # get min and max value for n_shapley_values min_node_value, max_node_value = np.min(first_order_values), np.max(first_order_values) @@ -63,7 +74,14 @@ def _add_weight_to_edges_in_graph( def _add_legend_to_axis(axis: plt.Axes) -> None: - """Adds a legend for order 1 (nodes) and order 2 (edges) interactions to the axis.""" + """Adds a legend for order 1 (nodes) and order 2 (edges) interactions to the axis. + + Args: + axis (plt.Axes): The axis to add the legend to. + + Returns: + None + """ sizes = [1.0, 0.2, 0.2, 1] labels = ["high pos.", "low pos.", "low neg.", "high neg."] alphas_line = [0.5, 0.2, 0.2, 0.5] @@ -131,9 +149,9 @@ def _add_legend_to_axis(axis: plt.Axes) -> None: def network_plot( *, - first_order_values: np.ndarray[float], - second_order_values: np.ndarray[float], - interaction_values: InteractionValues = None, + first_order_values: Optional[np.ndarray[float]] = None, + second_order_values: Optional[np.ndarray[float]] = None, + interaction_values: Optional[InteractionValues] = None, feature_names: Optional[list[Any]] = None, feature_image_patches: Optional[dict[int, Image.Image]] = None, feature_image_patches_size: Optional[Union[float, dict[int, float]]] = 0.2, From 1a2046cff77437d65fb959fca00129741e399bdc Mon Sep 17 00:00:00 2001 From: Maximilian Date: Mon, 5 Feb 2024 18:51:26 +0100 Subject: [PATCH 3/5] adds the stack bar plot for nSII values and closes #31 --- docs/source/_static/stacked_bar_exampl.png | Bin 0 -> 57908 bytes shapiq/plot/__init__.py | 5 +- shapiq/plot/n_sii_stacked_bar.py | 82 ------------- shapiq/plot/stacked_bar.py | 136 +++++++++++++++++++++ tests/tests_plots/test_stacked_bar.py | 45 +++++++ 5 files changed, 183 insertions(+), 85 deletions(-) create mode 100644 docs/source/_static/stacked_bar_exampl.png delete mode 100644 shapiq/plot/n_sii_stacked_bar.py create mode 100644 shapiq/plot/stacked_bar.py create mode 100644 tests/tests_plots/test_stacked_bar.py diff --git a/docs/source/_static/stacked_bar_exampl.png b/docs/source/_static/stacked_bar_exampl.png new file mode 100644 index 0000000000000000000000000000000000000000..9ee78ac4e8087492d8d35dd90e64ed0fa73a6774 GIT binary patch literal 57908 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. + + An example of the plot is shown below. + + .. image:: /_static/stacked_bar_exampl.png + :width: 400 + :align: center + + Args: + feature_names (list): The names of the features. + n_shapley_values_pos (dict): The positive n-SII values. + n_shapley_values_neg (dict): The negative n-SII values. + n_sii_max_order (int): The order of the n-SII values. + title (str): The title of the plot. + xlabel (str): The label of the x-axis. + ylabel (str): The label of the y-axis. + + Returns: + tuple[matplotlib.figure.Figure, matplotlib.axes.Axes]: A tuple containing the figure and + the axis of the plot. + + Note: + To change the figure size, font size, etc., use the [matplotlib parameters](https://matplotlib.org/stable/users/explain/customizing.html). + + Example: + >>> import numpy as np + >>> from shapiq.plot import stacked_bar_plot + >>> n_shapley_values_pos = { + ... 1: np.asarray([1, 0, 1.75]), + ... 2: np.asarray([0.25, 0.5, 0.75]), + ... 3: np.asarray([0.5, 0.25, 0.25]), + ... } + >>> n_shapley_values_neg = { + ... 1: np.asarray([0, -1.5, 0]), + ... 2: np.asarray([-0.25, -0.5, -0.75]), + ... 3: np.asarray([-0.5, -0.25, -0.25]), + ... } + >>> feature_names = ["a", "b", "c"] + >>> fig, axes = stacked_bar_plot( + ... feature_names=feature_names, + ... n_shapley_values_pos=n_shapley_values_pos, + ... n_shapley_values_neg=n_shapley_values_neg, + ... ) + >>> plt.show() + """ + # sanitize inputs + if n_sii_max_order is None: + n_sii_max_order = len(n_shapley_values_pos) + + fig, axis = plt.subplots() + + # transform data to make plotting easier + n_features = len(feature_names) + x = np.arange(n_features) + values_pos = np.array( + [values for order, values in n_shapley_values_pos.items() if order >= n_sii_max_order] + ) + values_neg = np.array( + [values for order, values in n_shapley_values_neg.items() if order >= n_sii_max_order] + ) + + # get helper variables for plotting the bars + min_max_values = [0, 0] # to set the y-axis limits after all bars are plotted + reference_pos = np.zeros(n_features) # to plot the bars on top of each other + reference_neg = deepcopy(values_neg[0]) # to plot the bars below of each other + + # plot the bar segments + for order in range(len(values_pos)): + axis.bar(x, height=values_pos[order], bottom=reference_pos, color=COLORS_N_SII[order]) + axis.bar(x, height=abs(values_neg[order]), bottom=reference_neg, color=COLORS_N_SII[order]) + axis.axhline(y=0, color="black", linestyle="solid", linewidth=0.5) + reference_pos += values_pos[order] + try: + reference_neg += values_neg[order + 1] + except IndexError: + pass + min_max_values[0] = min(min_max_values[0], min(reference_neg)) + min_max_values[1] = max(min_max_values[1], max(reference_pos)) + + # add a legend to the plots + legend_elements = [] + for order in range(n_sii_max_order): + legend_elements.append( + Patch(facecolor=COLORS_N_SII[order], edgecolor="black", label=f"Order {order + 1}") + ) + axis.legend(handles=legend_elements, loc="upper center", ncol=min(n_sii_max_order, 4)) + + x_ticks_labels = [feature for feature in feature_names] # might be unnecessary + axis.set_xticks(x) + axis.set_xticklabels(x_ticks_labels, rotation=45, ha="right") + + axis.set_xlim(-0.5, n_features - 0.5) + axis.set_ylim( + min_max_values[0] - abs(min_max_values[1] - min_max_values[0]) * 0.02, + min_max_values[1] + abs(min_max_values[1] - min_max_values[0]) * 0.3, + ) + + # set title and labels if not provided + + axis.set_title( + f"n-SII values up to order ${n_sii_max_order}$" + ) if title is None else axis.set_title(title) + + axis.set_xlabel("features") if xlabel is None else axis.set_xlabel(xlabel) + axis.set_ylabel("n-SII values") if ylabel is None else axis.set_ylabel(ylabel) + + plt.tight_layout() + + return fig, axis diff --git a/tests/tests_plots/test_stacked_bar.py b/tests/tests_plots/test_stacked_bar.py new file mode 100644 index 00000000..640b9fe8 --- /dev/null +++ b/tests/tests_plots/test_stacked_bar.py @@ -0,0 +1,45 @@ +"""This module contains all tests for the stacked bar plots.""" +import numpy as np + +import matplotlib.pyplot as plt + + +from shapiq.plot import stacked_bar_plot + + +def test_stacked_bar_plot(): + """Tests whether the stacked bar plot can be created.""" + + n_shapley_values_pos = { + 1: np.asarray([1, 0, 1.75]), + 2: np.asarray([0.25, 0.5, 0.75]), + 3: np.asarray([0.5, 0.25, 0.25]), + } + n_shapley_values_neg = { + 1: np.asarray([0, -1.5, 0]), + 2: np.asarray([-0.25, -0.5, -0.75]), + 3: np.asarray([-0.5, -0.25, -0.25]), + } + feature_names = ["a", "b", "c"] + fig, axes = stacked_bar_plot( + feature_names=feature_names, + n_shapley_values_pos=n_shapley_values_pos, + n_shapley_values_neg=n_shapley_values_neg, + ) + assert fig is not None + assert axes is not None + plt.close(fig) + assert True + + fig, axes = stacked_bar_plot( + feature_names=feature_names, + n_shapley_values_pos=n_shapley_values_pos, + n_shapley_values_neg=n_shapley_values_neg, + n_sii_max_order=2, + title="Title", + xlabel="X", + ylabel="Y", + ) + assert fig is not None + assert axes is not None + plt.close(fig) From 50876ced2fb6d108a6933f66e22e74c91337ec93 Mon Sep 17 00:00:00 2001 From: Maximilian Date: Tue, 6 Feb 2024 11:16:15 +0100 Subject: [PATCH 4/5] adds text support to network plot and fixes ordering and closes #33 --- shapiq/plot/_config.py | 2 + shapiq/plot/network.py | 374 ++++++++++++++++--------- tests/tests_plots/test_network_plot.py | 37 ++- 3 files changed, 274 insertions(+), 139 deletions(-) diff --git a/shapiq/plot/_config.py b/shapiq/plot/_config.py index 05166b06..539df662 100644 --- a/shapiq/plot/_config.py +++ b/shapiq/plot/_config.py @@ -5,12 +5,14 @@ "RED", "BLUE", "NEUTRAL", + "LINES", "COLORS_N_SII", ] RED = Color("#ff0d57") BLUE = Color("#1e88e5") NEUTRAL = Color("#ffffff") +LINES = Color("#cccccc") COLORS_N_SII = [ "#D81B60", diff --git a/shapiq/plot/network.py b/shapiq/plot/network.py index 080215d6..5004559f 100644 --- a/shapiq/plot/network.py +++ b/shapiq/plot/network.py @@ -11,152 +11,25 @@ from approximator._base import InteractionValues from utils import powerset -from ._config import BLUE, RED +from ._config import BLUE, RED, NEUTRAL, LINES __all__ = [ "network_plot", ] -def _get_color(value: float) -> str: - """Returns blue color for negative values and red color for positive values.""" - if value >= 0: - return RED.hex - return BLUE.hex - - -def _add_weight_to_edges_in_graph( - graph: nx.Graph, - first_order_values: np.ndarray, - second_order_values: np.ndarray, - n_features: int, - feature_names: list[str], -) -> None: - """Adds the weights to the edges in the graph. - - Args: - graph (nx.Graph): The graph to add the weights to. - first_order_values (np.ndarray): The first order n-SII values. - second_order_values (np.ndarray): The second order n-SII values. - n_features (int): The number of features. - feature_names (list[str]): The names of the features. - - Returns: - None - """ - - # get min and max value for n_shapley_values - min_node_value, max_node_value = np.min(first_order_values), np.max(first_order_values) - min_edge_value, max_edge_value = np.min(second_order_values), np.max(second_order_values) - - all_range = abs(max(max_node_value, max_edge_value) - min(min_node_value, min_edge_value)) - - size_scaler = 30 - - for node in graph.nodes: - weight: float = first_order_values[node] - size = abs(weight) / all_range - color = _get_color(weight) - graph.nodes[node]["node_color"] = color - graph.nodes[node]["node_size"] = size * 250 - graph.nodes[node]["label"] = feature_names[node] - graph.nodes[node]["linewidths"] = 1 - graph.nodes[node]["edgecolors"] = color - - for edge in powerset(range(n_features), min_size=2, max_size=2): - weight: float = float(second_order_values[edge]) - color = _get_color(weight) - # scale weight between min and max edge value - size = abs(weight) / all_range - graph_edge = graph.get_edge_data(*edge) - graph_edge["width"] = size * (size_scaler + 1) - graph_edge["color"] = color - - -def _add_legend_to_axis(axis: plt.Axes) -> None: - """Adds a legend for order 1 (nodes) and order 2 (edges) interactions to the axis. - - Args: - axis (plt.Axes): The axis to add the legend to. - - Returns: - None - """ - sizes = [1.0, 0.2, 0.2, 1] - labels = ["high pos.", "low pos.", "low neg.", "high neg."] - alphas_line = [0.5, 0.2, 0.2, 0.5] - - # order 1 (circles) - plot_circles = [] - for i in range(4): - size = sizes[i] - if i < 2: - color = RED.hex - else: - color = BLUE.hex - circle = axis.plot([], [], c=color, marker="o", markersize=size * 8, linestyle="None") - plot_circles.append(circle[0]) - - legend1 = plt.legend( - plot_circles, - labels, - frameon=True, - framealpha=0.5, - facecolor="white", - title=r"$\bf{Order\ 1}$", - fontsize=7, - labelspacing=0.5, - handletextpad=0.5, - borderpad=0.5, - handlelength=1.5, - bbox_to_anchor=(1.12, 1.1), - title_fontsize=7, - loc="upper right", - ) - - # order 2 (lines) - plot_lines = [] - for i in range(4): - size = sizes[i] - alpha = alphas_line[i] - if i < 2: - color = RED.hex - else: - color = BLUE.hex - line = axis.plot([], [], c=color, linewidth=size * 3, alpha=alpha) - plot_lines.append(line[0]) - - legend2 = plt.legend( - plot_lines, - labels, - frameon=True, - framealpha=0.5, - facecolor="white", - title=r"$\bf{Order\ 2}$", - fontsize=7, - labelspacing=0.5, - handletextpad=0.5, - borderpad=0.5, - handlelength=1.5, - bbox_to_anchor=(1.12, 0.92), - title_fontsize=7, - loc="upper right", - ) - - axis.add_artist(legend1) - axis.add_artist(legend2) - - def network_plot( + interaction_values: Optional[InteractionValues] = None, *, first_order_values: Optional[np.ndarray[float]] = None, second_order_values: Optional[np.ndarray[float]] = None, - interaction_values: Optional[InteractionValues] = None, feature_names: Optional[list[Any]] = None, feature_image_patches: Optional[dict[int, Image.Image]] = None, feature_image_patches_size: Optional[Union[float, dict[int, float]]] = 0.2, center_image: Optional[Image.Image] = None, center_image_size: Optional[float] = 0.6, + draw_legend: bool = True, + center_text: Optional[str] = None, ) -> tuple[plt.Figure, plt.Axes]: """Draws the interaction network. @@ -185,6 +58,8 @@ def network_plot( Defaults to 0.2. center_image: The image to be displayed in the center of the network. Defaults to None. center_image_size: The size of the center image. Defaults to 0.6. + draw_legend: Whether to draw the legend. Defaults to True. + center_text: The text to be displayed in the center of the network. Defaults to None. Returns: The figure and the axis containing the plot. @@ -192,6 +67,22 @@ def network_plot( fig, axis = plt.subplots(figsize=(6, 6)) axis.axis("off") + if interaction_values is not None: + n_players = interaction_values.n_players + first_order_values = np.zeros(n_players) + second_order_values = np.zeros((n_players, n_players)) + for interaction in powerset(range(n_players), min_size=1, max_size=2): + if len(interaction) == 1: + first_order_values[interaction[0]] = interaction_values[interaction] + else: + second_order_values[interaction] = interaction_values[interaction] + else: + if first_order_values is None or second_order_values is None: + raise ValueError( + "Either interaction_values or first_order_values and second_order_values must be " + "provided. If interaction_values is provided this will be used." + ) + # get the number of features and the feature names n_features = first_order_values.shape[0] if feature_names is None: @@ -200,6 +91,8 @@ def network_plot( # create a fully connected graph up to the n_sii_order graph = nx.complete_graph(n_features) + nodes_visit_order = _order_nodes(len(graph.nodes)) + # add the weights to the edges _add_weight_to_edges_in_graph( graph=graph, @@ -207,6 +100,7 @@ def network_plot( second_order_values=second_order_values, n_features=n_features, feature_names=feature_names, + nodes_visit_order=nodes_visit_order, ) # get node and edge attributes @@ -235,9 +129,10 @@ def network_plot( ) # add the labels or image patches to the nodes - for node, (x, y) in pos.items(): + for i, node in enumerate(nodes_visit_order): + (x, y) = pos[node] size = graph.nodes[node]["linewidths"] - label = node_labels[node] + label = node_labels[i] radius = 1.15 + size / 300 theta = np.arctan2(x, y) if abs(theta) <= 0.001: @@ -251,10 +146,10 @@ def network_plot( if feature_image_patches is None: axis.text(x, y, label, horizontalalignment="center", verticalalignment="center") else: # draw the image instead of the text - image = feature_image_patches[node] + image = feature_image_patches[i] patch_size = feature_image_patches_size if isinstance(patch_size, dict): - patch_size = patch_size[node] + patch_size = patch_size[i] extend = patch_size / 2 axis.imshow(image, extent=(x - extend, x + extend, y - extend, y + extend)) @@ -262,16 +157,184 @@ def network_plot( if center_image is not None: _add_center_image(axis, center_image, center_image_size, n_features) + # add the center text if provided + if center_text is not None: + background_color = NEUTRAL.hex + line_color = LINES.hex + axis.text( + 0, + 0, + center_text, + horizontalalignment="center", + verticalalignment="center", + bbox=dict(facecolor=background_color, alpha=0.5, edgecolor=line_color, pad=7), + color="black", + fontsize=plt.rcParams["font.size"] + 3, + ) + # add the legends to the plot - _add_legend_to_axis(axis) + if draw_legend: + _add_legend_to_axis(axis) return fig, axis +def _get_color(value: float) -> str: + """Returns blue color for negative values and red color for positive values. + + Args: + value (float): The value to determine the color for. + + Returns: + str: The color as a hex string. + """ + if value >= 0: + return RED.hex + return BLUE.hex + + +def _add_weight_to_edges_in_graph( + graph: nx.Graph, + first_order_values: np.ndarray, + second_order_values: np.ndarray, + n_features: int, + feature_names: list[str], + nodes_visit_order: list[int], +) -> None: + """Adds the weights to the edges in the graph. + + Args: + graph (nx.Graph): The graph to add the weights to. + first_order_values (np.ndarray): The first order n-SII values. + second_order_values (np.ndarray): The second order n-SII values. + n_features (int): The number of features. + feature_names (list[str]): The names of the features. + nodes_visit_order (list[int]): The order of the nodes to visit. + + Returns: + None + """ + + # get min and max value for n_shapley_values + min_node_value, max_node_value = np.min(first_order_values), np.max(first_order_values) + min_edge_value, max_edge_value = np.min(second_order_values), np.max(second_order_values) + + all_range = abs(max(max_node_value, max_edge_value) - min(min_node_value, min_edge_value)) + + size_scaler = 30 + + for i, node_id in enumerate(nodes_visit_order): + weight: float = first_order_values[i] + size = abs(weight) / all_range + color = _get_color(weight) + graph.nodes[node_id]["node_color"] = color + graph.nodes[node_id]["node_size"] = size * 250 + graph.nodes[node_id]["label"] = feature_names[node_id] + graph.nodes[node_id]["linewidths"] = 1 + graph.nodes[node_id]["edgecolors"] = color + + for interaction in powerset(range(n_features), min_size=2, max_size=2): + weight: float = float(second_order_values[interaction]) + edge = list(sorted(interaction)) + edge[0] = nodes_visit_order.index(interaction[0]) + edge[1] = nodes_visit_order.index(interaction[1]) + edge = tuple(edge) + color = _get_color(weight) + # scale weight between min and max edge value + size = abs(weight) / all_range + graph_edge = graph.get_edge_data(*edge) + graph_edge["width"] = size * (size_scaler + 1) + graph_edge["color"] = color + + +def _add_legend_to_axis(axis: plt.Axes) -> None: + """Adds a legend for order 1 (nodes) and order 2 (edges) interactions to the axis. + + Args: + axis (plt.Axes): The axis to add the legend to. + + Returns: + None + """ + sizes = [1.0, 0.2, 0.2, 1] + labels = ["high pos.", "low pos.", "low neg.", "high neg."] + alphas_line = [0.5, 0.2, 0.2, 0.5] + + # order 1 (circles) + plot_circles = [] + for i in range(4): + size = sizes[i] + if i < 2: + color = RED.hex + else: + color = BLUE.hex + circle = axis.plot([], [], c=color, marker="o", markersize=size * 8, linestyle="None") + plot_circles.append(circle[0]) + + font_size = plt.rcParams["legend.fontsize"] + + legend1 = plt.legend( + plot_circles, + labels, + frameon=True, + framealpha=0.5, + facecolor="white", + title=r"$\bf{Order\ 1}$", + fontsize=font_size, + labelspacing=0.5, + handletextpad=0.5, + borderpad=0.5, + handlelength=1.5, + title_fontsize=font_size, + loc="best", + ) + + # order 2 (lines) + plot_lines = [] + for i in range(4): + size = sizes[i] + alpha = alphas_line[i] + if i < 2: + color = RED.hex + else: + color = BLUE.hex + line = axis.plot([], [], c=color, linewidth=size * 3, alpha=alpha) + plot_lines.append(line[0]) + + legend2 = plt.legend( + plot_lines, + labels, + frameon=True, + framealpha=0.5, + facecolor="white", + title=r"$\bf{Order\ 2}$", + fontsize=font_size, + labelspacing=0.5, + handletextpad=0.5, + borderpad=0.5, + handlelength=1.5, + title_fontsize=font_size, + loc="best", + ) + + axis.add_artist(legend1) + axis.add_artist(legend2) + + def _add_center_image( axis: plt.Axes, center_image: Image.Image, center_image_size: float, n_features: int -): - """Adds the center image to the axis.""" +) -> None: + """Adds the center image to the axis. + + Args: + axis (plt.Axes): The axis to add the image to. + center_image (Image.Image): The image to add to the axis. + center_image_size (float): The size of the center image. + n_features (int): The number of features. + + Returns: + None + """ # plot the center image image_to_plot = Image.fromarray(np.asarray(copy.deepcopy(center_image))) extend = center_image_size @@ -287,3 +350,38 @@ def _add_center_image( axis.set_zorder(1) for edge in axis.collections: edge.set_zorder(0) + + +def _get_highest_node_index(n_nodes: int) -> int: + """Calculates the node with the highest position on the y-axis given the total number of nodes. + + Args: + n_nodes (int): The total number of nodes. + + Returns: + int: The index of the highest node. + """ + n_connections = 0 + # highest node is the last node below 1/4 of all connections in the circle + while n_connections <= n_nodes / 4: + n_connections += 1 + n_connections -= 1 + return n_connections + + +def _order_nodes(n_nodes: int) -> list[int]: + """Orders the nodes in the network plot. + + Args: + n_nodes (int): The total number of nodes. + + Returns: + list[int]: The order of the nodes. + """ + highest_node = _get_highest_node_index(n_nodes) + nodes_visit_order = [highest_node] + desired_order = list(reversed(list(range(n_nodes)))) + highest_node_index = desired_order.index(highest_node) + nodes_visit_order += desired_order[highest_node_index + 1 :] + nodes_visit_order += desired_order[:highest_node_index] + return nodes_visit_order diff --git a/tests/tests_plots/test_network_plot.py b/tests/tests_plots/test_network_plot.py index 47404c39..48db6c8e 100644 --- a/tests/tests_plots/test_network_plot.py +++ b/tests/tests_plots/test_network_plot.py @@ -1,9 +1,12 @@ """This module contains all tests for the network plots.""" import numpy as np import matplotlib.pyplot as plt +import pytest from PIL import Image +from scipy.special import binom from shapiq.plot import network_plot +from shapiq.approximator._base import InteractionValues def test_network_plot(): @@ -29,8 +32,29 @@ def test_network_plot(): assert axes is not None plt.close(fig) + # test with InteractionValues object + n_players = 5 + n_values = n_players + int(binom(n_players, 2)) + iv = InteractionValues( + values=np.random.rand(n_values), + index="nSII", + n_players=n_players, + min_order=1, + max_order=2, + ) + fig, axes = network_plot(interaction_values=iv) + assert fig is not None + assert axes is not None + plt.close(fig) -def test_network_plot_with_image(): + # value error if neither first_order_values nor interaction_values are given + with pytest.raises(ValueError): + network_plot() + + assert True + + +def test_network_plot_with_image_or_text(): first_order_values = np.asarray([0.1, -0.2, 0.3, 0.4, 0.5, 0.6]) second_order_values = np.random.rand(6, 6) - 0.5 n_features = len(first_order_values) @@ -66,3 +90,14 @@ def test_network_plot_with_image(): assert fig is not None assert axes is not None plt.close(fig) + + # with text + fig, axes = network_plot( + first_order_values=first_order_values, + second_order_values=second_order_values, + center_text="center text", + ) + assert fig is not None + assert axes is not None + plt.close(fig) + assert True From c564c36fa035fd8685002c700eda1446b74917a2 Mon Sep 17 00:00:00 2001 From: Maximilian Date: Tue, 6 Feb 2024 11:19:07 +0100 Subject: [PATCH 5/5] add stacked bar plot to import --- shapiq/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shapiq/__init__.py b/shapiq/__init__.py index 72ea5e39..717989af 100644 --- a/shapiq/__init__.py +++ b/shapiq/__init__.py @@ -19,7 +19,7 @@ from .games import DummyGame # plotting functions -from .plot import network_plot +from .plot import network_plot, stacked_bar_plot # public utils functions from .utils import ( # sets.py # tree.py @@ -48,6 +48,7 @@ "DummyGame", # plots "network_plot", + "stacked_bar_plot", # public utils "powerset", "get_explicit_subsets",