• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mmschlk / shapiq / 16345920221

17 Jul 2025 01:05PM UTC coverage: 77.589% (-13.5%) from 91.075%
16345920221

push

github

web-flow
🔨 Refactors library into a src structure. (#415)

* moves shapiq into a src folder

* moves shapiq tests into tests_shapiq subfolder in tests

* refactors tests to work properly

* removes pickle support and closes #413

* changes unit tests to only run the unit tests

* adds workflow for running shapiq_games

* updates coverage to only run for shapiq

* update workflow to check for shapiq_games import

* update CHANGELOG.md

* fixes install-import.yml

* fixes version in docs

* moved deprecated tests out of the main test suite

* moves fixtures in the correct test suite

* installs libomp on macos runner (try bugfix)

* correct spelling

* removes libomp again

* moves os runs into individual workflows for easier debugging

* runs macOS on py3.13

* renames workflows

* installs libomp again on macOS

* downgraded to 3.11 and reinstall python

* try different uv version

* adds libomp

* changes skip to xfail in integration tests with wrong index/order combinations

* moves test out for debugging CI

* removes outdated test

* adds concurrency for quicker testsing

* re-adds randomly

* dont reset seed

* removed pytest-randomly again

* adds the tests back in

3 of 21 new or added lines in 19 files covered. (14.29%)

5536 of 7135 relevant lines covered (77.59%)

0.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.44
/src/shapiq/utils/sets.py
1
"""This module contains utility functions for dealing with sets, coalitions and game theory."""
2

3
from __future__ import annotations
1✔
4

5
import copy
1✔
6
from itertools import chain, combinations
1✔
7
from typing import TYPE_CHECKING, TypeVar
1✔
8

9
import numpy as np
1✔
10
from scipy.special import binom
1✔
11

12
if TYPE_CHECKING:
1✔
13
    from collections.abc import Iterable, Iterator
×
14

NEW
15
    from shapiq.typing import CoalitionMatrix, CoalitionsLookup, CoalitionsTuples
×
16

17

18
__all__ = [
1✔
19
    "count_interactions",
20
    "generate_interaction_lookup",
21
    "get_explicit_subsets",
22
    "pair_subset_sizes",
23
    "powerset",
24
    "split_subsets_budget",
25
    "transform_array_to_coalitions",
26
    "transform_coalitions_to_array",
27
]
28

29
T = TypeVar("T", int, str)
1✔
30

31

32
def powerset(
1✔
33
    iterable: Iterable[T],
34
    min_size: int = 0,
35
    max_size: int | None = None,
36
) -> Iterator[tuple[T, ...]]:
37
    """Return a powerset of an iterable as tuples with optional size limits.
38

39
    Args:
40
        iterable: An iterable (e.g., list, set, etc.) from which to generate the powerset.
41
        min_size: Minimum size of the subsets. Defaults to 0 (start with the empty set).
42
        max_size: Maximum size of the subsets. Defaults to None (all possible sizes).
43

44
    Returns:
45
        iterable: Powerset of the iterable.
46

47
    Example:
48
        >>> list(powerset([1, 2, 3]))
49
        [(), (1,), (2,), (3,), (1, 2), (1, 3), (2, 3), (1, 2, 3)]
50

51
        >>> list(powerset([1, 2, 3], min_size=1))
52
        [(1,), (2,), (3,), (1, 2), (1, 3), (2, 3), (1, 2, 3)]
53

54
        >>> list(powerset([1, 2, 3], max_size=2))
55
        [(), (1,), (2,), (3,), (1, 2), (1, 3), (2, 3)]
56

57
        >>> list(powerset(["A", "B", "C"], min_size=1, max_size=2))
58
        [('A',), ('B',), ('C',), ('A', 'B'), ('A', 'C'), ('B', 'C')]
59

60
    """
61
    s = sorted(iterable)
1✔
62
    max_size = len(s) if max_size is None else min(max_size, len(s))
1✔
63
    return chain.from_iterable(combinations(s, r) for r in range(max(min_size, 0), max_size + 1))
1✔
64

65

66
def pair_subset_sizes(order: int, n: int) -> tuple[list[tuple[int, int]], int | None]:
1✔
67
    """Determines what subset sizes are paired together.
68

69
    Given an interaction order and the number of players, determines the paired subsets. Paired
70
    subsets are subsets of the same size that are paired together moving from the smallest subset
71
    paired with the largest subset to the center.
72

73
    Args:
74
        order: interaction order.
75
        n: number of players.
76

77
    Returns:
78
        paired and unpaired subsets. If there is no unpaired subset `unpaired_subset` is None.
79

80
    Examples:
81
        >>> pair_subset_sizes(order=1, n=5)
82
        ([(1, 4), (2, 3)], None)
83

84
        >>> pair_subset_sizes(order=1, n=6)
85
        ([(1, 5), (2, 4)], 3)
86

87
        >>> pair_subset_sizes(order=2, n=5)
88
        ([(2, 3)], None)
89

90
    """
91
    subset_sizes = list(range(order, n - order + 1))
1✔
92
    n_paired_subsets = len(subset_sizes) // 2
1✔
93
    paired_subsets = [
1✔
94
        (subset_sizes[size - 1], subset_sizes[-size]) for size in range(1, n_paired_subsets + 1)
95
    ]
96
    unpaired_subset = None if len(subset_sizes) % 2 == 0 else subset_sizes[n_paired_subsets]
1✔
97
    return paired_subsets, unpaired_subset
1✔
98

99

100
def split_subsets_budget(
1✔
101
    order: int,
102
    n: int,
103
    budget: int,
104
    sampling_weights: np.ndarray,
105
) -> tuple[list, list, int]:
106
    """Determines which subset sizes can be computed explicitly and which sizes need to be sampled.
107

108
    Given a computational budget, determines the complete subsets that can be computed explicitly
109
    and the corresponding incomplete subsets that need to be estimated via sampling.
110

111
    Args:
112
        order: interaction order.
113
        n: number of players.
114
        budget: total allowed budget for the computation.
115
        sampling_weights: weight vector of the sampling distribution in shape (n + 1,). The first
116
            and last element constituting the empty and full subsets are not used.
117

118
    Returns:
119
        complete subsets, incomplete subsets, remaining budget
120

121
    Examples:
122
        >>> split_subsets_budget(order=1, n=6, budget=100, sampling_weights=np.ones(shape=(6,)))
123
        ([1, 5, 2, 4, 3], [], 38)
124

125
        >>> split_subsets_budget(order=1, n=6, budget=60, sampling_weights=np.ones(shape=(6,)))
126
        ([1, 5, 2, 4], [3], 18)
127

128
        >>> split_subsets_budget(order=1, n=6, budget=100, sampling_weights=np.zeros(shape=(6,)))
129
        ([], [1, 2, 3, 4, 5], 100)
130

131
    """
132
    # determine paired and unpaired subsets
133
    complete_subsets: list[int] = []
1✔
134
    paired_subsets, unpaired_subset = pair_subset_sizes(order, n)
1✔
135
    incomplete_subsets = list(range(order, n - order + 1))
1✔
136

137
    # turn weight vector into probability vector
138
    weight_vector = copy.copy(sampling_weights)
1✔
139
    weight_vector[0], weight_vector[-1] = 0, 0  # zero out the empty and full subsets
1✔
140
    sum_weight_vector = np.sum(weight_vector)
1✔
141
    weight_vector = np.divide(
1✔
142
        weight_vector,
143
        sum_weight_vector,
144
        out=weight_vector,
145
        where=sum_weight_vector != 0,
146
    )
147

148
    # check if the budget is sufficient to compute all paired subset sizes explicitly
149
    allowed_budget = weight_vector * budget  # allowed budget for each subset size
1✔
150
    for subset_size_1, subset_size_2 in paired_subsets:
1✔
151
        subset_budget = int(binom(n, subset_size_1))  # required budget for full computation
1✔
152
        # check if the budget is sufficient to compute the paired subset sizes explicitly
153
        if allowed_budget[subset_size_1] >= subset_budget and allowed_budget[subset_size_1] > 0:
1✔
154
            complete_subsets.extend((subset_size_1, subset_size_2))
1✔
155
            incomplete_subsets.remove(subset_size_1)
1✔
156
            incomplete_subsets.remove(subset_size_2)
1✔
157
            weight_vector[subset_size_1], weight_vector[subset_size_2] = 0, 0  # zero used sizes
1✔
158
            if np.sum(weight_vector) != 0:
1✔
159
                weight_vector /= np.sum(weight_vector)  # re-normalize into probability vector
1✔
160
            budget -= subset_budget * 2
1✔
161
        else:  # if the budget is not sufficient, return the current state
162
            return complete_subsets, incomplete_subsets, budget
1✔
163
        allowed_budget = weight_vector * budget  # update allowed budget for each subset size
1✔
164

165
    # check if the budget is sufficient to compute the unpaired subset size explicitly
166
    if unpaired_subset is not None:
1✔
167
        subset_budget = int(binom(n, unpaired_subset))
1✔
168
        if budget - subset_budget >= 0:
1✔
169
            complete_subsets.append(unpaired_subset)
1✔
170
            incomplete_subsets.remove(unpaired_subset)
1✔
171
            budget -= subset_budget
1✔
172
    return complete_subsets, incomplete_subsets, budget
1✔
173

174

175
def get_explicit_subsets(n: int, subset_sizes: list[int]) -> np.ndarray:
1✔
176
    """Enumerates all subsets of the given sizes and returns a one-hot matrix.
177

178
    Args:
179
        n: number of players.
180
        subset_sizes: list of subset sizes.
181

182
    Returns:
183
        one-hot matrix of all subsets of certain sizes.
184

185
    Examples:
186
        >>> get_explicit_subsets(n=4, subset_sizes=[1, 2]).astype(int)
187
        array([[1, 0, 0, 0],
188
               [0, 1, 0, 0],
189
               [0, 0, 1, 0],
190
               [0, 0, 0, 1],
191
               [1, 1, 0, 0],
192
               [1, 0, 1, 0],
193
               [1, 0, 0, 1],
194
               [0, 1, 1, 0],
195
               [0, 1, 0, 1],
196
               [0, 0, 1, 1]])
197

198
    """
199
    total_subsets = int(sum(binom(n, size) for size in subset_sizes))
1✔
200
    subset_matrix = np.zeros(shape=(total_subsets, n), dtype=bool)
1✔
201
    subset_index = 0
1✔
202
    for subset_size in subset_sizes:
1✔
203
        for subset in combinations(range(n), subset_size):
1✔
204
            subset_matrix[subset_index, subset] = True
1✔
205
            subset_index += 1
1✔
206
    return subset_matrix
1✔
207

208

209
def generate_interaction_lookup(
1✔
210
    players: Iterable[T] | int,
211
    min_order: int,
212
    max_order: int | None = None,
213
) -> dict[tuple[T, ...], int] | dict[tuple[int, ...], int]:
214
    """Generates a lookup dictionary for interactions.
215

216
    Args:
217
        players: A unique set of players or an Integer denoting the number of players.
218
        min_order: The minimum order of the approximation.
219
        max_order: The maximum order of the approximation.
220

221
    Returns:
222
        A dictionary that maps interactions to their index in the values vector.
223

224
    Example:
225
        >>> generate_interaction_lookup(3, 1, 3)
226
        {(0,): 0, (1,): 1, (2,): 2, (0, 1): 3, (0, 2): 4, (1, 2): 5, (0, 1, 2): 6}
227
        >>> generate_interaction_lookup(3, 2, 2)
228
        {(0, 1): 0, (0, 2): 1, (1, 2): 2}
229
        >>> generate_interaction_lookup(["A", "B", "C"], 1, 2)
230
        {('A',): 0, ('B',): 1, ('C',): 2, ('A', 'B'): 3, ('A', 'C'): 4, ('B', 'C'): 5}
231

232
    """
233
    if isinstance(players, int):
1✔
234
        return {
1✔
235
            interaction: i
236
            for i, interaction in enumerate(
237
                powerset(range(players), min_size=min_order, max_size=max_order)
238
            )
239
        }
240
    return {
1✔
241
        interaction: i
242
        for i, interaction in enumerate(powerset(players, min_size=min_order, max_size=max_order))
243
    }
244

245

246
def generate_interaction_lookup_from_coalitions(coalitions: CoalitionMatrix) -> CoalitionsLookup:
1✔
247
    """Generates a lookup dictionary for interactions based on an array of coalitions.
248

249
    Args:
250
        coalitions: An array of player coalitions.
251

252
    Returns:
253
        A dictionary that maps interactions to their index in the values vector
254

255
    Example:
256
        >>> coalitions = np.array([
257
        ...     [1, 0, 1],
258
        ...     [0, 1, 1],
259
        ...     [1, 1, 0],
260
        ...     [0, 0, 1]
261
        ... ])
262
        >>> generate_interaction_lookup_from_coalitions(coalitions)
263
        {(0, 2): 0, (1, 2): 1, (0, 1): 2, (2,): 3}
264

265
    """
266
    return {tuple(np.where(coalition)[0]): idx for idx, coalition in enumerate(coalitions)}
1✔
267

268

269
def transform_coalitions_to_array(
1✔
270
    coalitions: CoalitionsTuples,
271
    n_players: int | None = None,
272
) -> CoalitionMatrix:
273
    """Transforms a collection of coalitions to a binary array (one-hot encodings).
274

275
    Args:
276
        coalitions: Collection of coalitions.
277
        n_players: Number of players. Defaults to None (determined from the coalitions). If
278
            provided, n_players must be greater than the maximum player index in the coalitions.
279

280
    Returns:
281
        Binary array of coalitions.
282

283
    Example:
284
        >>> coalitions = [(0, 1), (1, 2), (0, 2)]
285
        >>> transform_coalitions_to_array(coalitions)
286
        array([[ True,  True, False],
287
               [False,  True,  True],
288
               [ True, False,  True]])
289

290
        >>> transform_coalitions_to_array(coalitions, n_players=4)
291
        array([[ True,  True, False, False],
292
               [False,  True,  True, False],
293
               [ True, False,  True, False]])
294

295
    """
296
    n_coalitions = len(coalitions)
1✔
297
    if n_players is None:
1✔
298
        n_players = max(max(coalition) for coalition in coalitions) + 1
1✔
299

300
    coalition_array = np.zeros((n_coalitions, n_players), dtype=bool)
1✔
301
    for i, coalition in enumerate(coalitions):
1✔
302
        coalition_array[i, coalition] = True
1✔
303
    return coalition_array
1✔
304

305

306
def transform_array_to_coalitions(coalitions: CoalitionMatrix) -> CoalitionsTuples:
1✔
307
    """Transforms a 2d one-hot matrix of coalitions into a list of tuples.
308

309
    Args:
310
        coalitions: A binary array of coalitions.
311

312
    Returns:
313
        List of coalitions as tuples.
314

315
    Examples:
316
        >>> coalitions = np.array([[True, True, False], [False, True, True], [True, False, True]])
317
        >>> transform_array_to_coalitions(coalitions)
318
        [(0, 1), (1, 2), (0, 2)]
319

320
        >>> coalitions = np.array([[False, False, False], [True, True, True]])
321
        >>> transform_array_to_coalitions(coalitions)
322
        [(), (0, 1, 2)]
323

324
    """
325
    return [tuple(np.where(coalition)[0]) for coalition in coalitions]
1✔
326

327

328
def count_interactions(n: int, max_order: int | None = None, min_order: int = 0) -> int:
1✔
329
    """Counts the number of interactions for a given number of players and maximum order.
330

331
    Args:
332
        n: Number of players.
333
        max_order: Maximum order of the interactions. If `None`, it is set to the number of players.
334
            Defaults to `None`.
335
        min_order: Minimum order of the interactions. Defaults to 0.
336

337
    Returns:
338
        The number of interactions.
339

340
    Examples:
341
        >>> count_interactions(3)
342
        8
343
        >>> count_interactions(3, 2)
344
        7
345
        >>> count_interactions(3, 2, 1)
346
        6
347

348
    """
349
    if max_order is None:
1✔
350
        max_order = n
1✔
351
    return int(sum(binom(n, size) for size in range(min_order, max_order + 1)))
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc