Skip to content

Commit d3aba71

Browse files
committed
fix overlapping crossbars
1 parent 98665e2 commit d3aba71

File tree

2 files changed

+114
-37
lines changed

2 files changed

+114
-37
lines changed

scikit_posthocs/_plotting.py

Lines changed: 46 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
from copy import deepcopy
21
from typing import Dict, List, Optional, Set, Tuple, Union
2+
from itertools import combinations
33

44
import numpy as np
55
from matplotlib import colors, pyplot
@@ -42,13 +42,12 @@ def sign_array(p_values: Union[List, np.ndarray, DataFrame], alpha: float = 0.05
4242
[ 1, -1, 0],
4343
[ 1, 0, -1]])
4444
"""
45-
sig_array = deepcopy(np.array(p_values))
46-
sig_array[sig_array == 0] = 1e-10
47-
sig_array[sig_array > alpha] = 0
48-
sig_array[(sig_array < alpha) & (sig_array > 0)] = 1
49-
np.fill_diagonal(sig_array, -1)
50-
51-
return sig_array
45+
p_values = np.asarray(p_values)
46+
if (p_values < 0).any():
47+
raise ValueError("P values matrix must be non-negative")
48+
result = (p_values <= alpha).astype(np.int8) # Returns a copy
49+
np.fill_diagonal(result, -1)
50+
return result
5251

5352

5453
def sign_table(
@@ -518,41 +517,51 @@ def critical_difference_diagram(
518517
ranks.iloc[: len(ranks) // 2],
519518
ranks.iloc[len(ranks) // 2 :],
520519
)
521-
# points_left, points_right = np.array_split(ranks.sort_values(), 2)
522520

523521
# Sets of points under the same crossbar
524-
crossbar_sets = _find_maximal_cliques(adj_matrix)
522+
crossbar_sets = [bar for bar in _find_maximal_cliques(adj_matrix) if len(bar) > 1]
525523

526-
# Sort by lowest rank and filter single-valued sets
527-
crossbar_sets = sorted(
528-
(x for x in crossbar_sets if len(x) > 1), key=lambda x: ranks[list(x)].min()
529-
)
524+
if not crossbar_sets: # All points are significantly different
525+
# The list of crossbars is left empty
526+
lowest_crossbar_ypos = -1
527+
else:
528+
crossbar_min_max = [ # Will be used to check if two crossbars intersect
529+
ranks.reindex(bar).agg(["min", "max"])
530+
for bar in crossbar_sets
531+
]
532+
533+
# Create an adjacency matrix of the crossbars, where 1 means that the two
534+
# crossbars do not intersect, meaning that they can be plotted on the same
535+
# level.
536+
n_bars = len(crossbar_sets)
537+
on_same_level = DataFrame(True, index=range(n_bars), columns=range(n_bars))
538+
539+
for (i, bar_i), (j, bar_j) in combinations(enumerate(crossbar_min_max), 2):
540+
on_same_level.loc[i, j] = on_same_level.loc[j, i] = (
541+
(bar_i["max"] < bar_j["min"]) or (bar_i["min"] > bar_j["max"])
542+
)
530543

531-
# Create stacking of crossbars: for each level, try to fit the crossbar,
532-
# so that it does not intersect with any other in the level. If it does not
533-
# fit in any level, create a new level for it.
534-
crossbar_levels: list[list[set]] = []
535-
for bar in crossbar_sets:
544+
# The levels are the maximal cliques of the crossbar adjacency matrix.
545+
crossbar_levels = _find_maximal_cliques(on_same_level)
546+
547+
# Plot the crossbars in each level
548+
crossbars = []
536549
for level, bars_in_level in enumerate(crossbar_levels):
537-
if not any(bool(bar & bar_in_lvl) for bar_in_lvl in bars_in_level):
538-
ypos = -level - 1
539-
bars_in_level.append(bar)
540-
break
541-
else:
542-
ypos = -len(crossbar_levels) - 1
543-
crossbar_levels.append([bar])
544-
545-
crossbars.append(
546-
ax.plot(
547-
# Adding a separate line between each pair enables showing a
548-
# marker over each elbow with crossbar_props={'marker': 'o'}.
549-
[ranks[i] for i in bar],
550-
[ypos] * len(bar),
551-
**crossbar_props,
552-
)
553-
)
550+
plotted_bars_in_level = []
551+
for bar_index in bars_in_level:
552+
bar = crossbar_sets[bar_index]
553+
plotted_bar, *_ = ax.plot(
554+
# We could plot a single line segment between min and max. However,
555+
# adding a separate segment between each pair enables showing a
556+
# marker over each elbow, e.g. crossbar_props={'marker': 'o'}.
557+
[ranks[i] for i in bar],
558+
[-level - 1] * len(bar),
559+
**crossbar_props,
560+
)
561+
plotted_bars_in_level.append(plotted_bar)
562+
crossbars.append(plotted_bars_in_level)
554563

555-
lowest_crossbar_ypos = -len(crossbar_levels)
564+
lowest_crossbar_ypos = -len(crossbar_levels)
556565

557566
def plot_items(points, xpos, label_fmt, color_palette, label_props):
558567
"""Plot each marker + elbow + label."""

tests/test_posthocs.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,20 @@ def test_find_maximal_cliques_6x6(self):
160160
set(map(frozenset, expected)),
161161
)
162162

163+
def test_cd_diagram_single_bar(self):
164+
index = list("abcdef")
165+
ranks = Series([2.1, 1.2, 4.5, 3.2, 5.7, 6.5], index=index)
166+
sig_matrix = DataFrame(
167+
1, # No significant differences
168+
index=index,
169+
columns=index,
170+
)
171+
output = splt.critical_difference_diagram(ranks, sig_matrix)
172+
self.assertEqual(len(output["markers"]), len(ranks))
173+
self.assertEqual(len(output["elbows"]), len(ranks))
174+
self.assertEqual(len(output["labels"]), len(ranks))
175+
self.assertEqual(len(output["crossbars"]), 1)
176+
163177
def test_cd_diagram_number_of_artists(self):
164178
index = list("abcdef")
165179
ranks = Series([2.1, 1.2, 4.5, 3.2, 5.7, 6.5], index=index)
@@ -182,6 +196,60 @@ def test_cd_diagram_number_of_artists(self):
182196
self.assertEqual(len(output["labels"]), len(ranks))
183197
self.assertEqual(len(output["crossbars"]), 2)
184198

199+
def test_cd_diagram_all_significant(self):
200+
index = list("abcdef")
201+
ranks = Series(np.arange(len(index)), index=index)
202+
sig_matrix = DataFrame(
203+
np.eye(len(index)), # All significant
204+
index=index,
205+
columns=index,
206+
)
207+
output = splt.critical_difference_diagram(ranks, sig_matrix)
208+
self.assertEqual(len(output["markers"]), len(ranks))
209+
self.assertEqual(len(output["elbows"]), len(ranks))
210+
self.assertEqual(len(output["labels"]), len(ranks))
211+
self.assertEqual(len(output["crossbars"]), 0)
212+
213+
def test_cd_diagram_non_intersecting_crossbars(self):
214+
index = list("abcdef")
215+
# Swap the ranks of 'c' and 'd'
216+
ranks = Series([0, 1, 3, 2, 4, 5], index=index)
217+
sig_matrix = DataFrame(
218+
[
219+
[1, 1, 1, 0, 0, 0],
220+
[1, 1, 1, 0, 0, 0],
221+
[1, 1, 1, 0, 0, 0],
222+
[0, 0, 0, 1, 1, 1],
223+
[0, 0, 0, 1, 1, 1],
224+
[0, 0, 0, 1, 1, 1],
225+
],
226+
index=index,
227+
columns=index,
228+
)
229+
output = splt.critical_difference_diagram(ranks, sig_matrix)
230+
crossbars = output["crossbars"]
231+
y_positions = set(bar.get_ydata()[0] for level in crossbars for bar in level)
232+
self.assertEqual(len(crossbars), len(y_positions))
233+
234+
def test_cd_diagram_normal_distributions(self):
235+
rng = np.random.default_rng(0)
236+
experiment_values = rng.normal(
237+
loc=[-5.2, -6, -2.1, -1.7, -6.4],
238+
scale=np.full(fill_value=.1, shape=(10, 1)),
239+
)
240+
df = DataFrame(experiment_values, columns=["A", "B", "C", "D", "E"])
241+
242+
test_result = sp.posthoc_conover_friedman(df.to_numpy())
243+
average_ranks = df.rank(ascending=False, axis=1).mean(axis=0)
244+
245+
output = splt.critical_difference_diagram(
246+
ranks=average_ranks, sig_matrix=test_result
247+
)
248+
self.assertEqual(len(output["markers"]), df.shape[1])
249+
self.assertEqual(len(output["elbows"]), df.shape[1])
250+
self.assertEqual(len(output["labels"]), df.shape[1])
251+
self.assertEqual(len(output["crossbars"]), 0)
252+
185253
# Outliers tests
186254
def test_outliers_iqr(self):
187255
x = np.array([4, 5, 6, 10, 12, 4, 3, 1, 2, 3, 23, 5, 3])

0 commit comments

Comments
 (0)