-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinterp_rocket.py
More file actions
1831 lines (1594 loc) · 66.5 KB
/
Copy pathinterp_rocket.py
File metadata and controls
1831 lines (1594 loc) · 66.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Interpretable MultiRocket for univariate time-series classification.
The module provides a transparent, numerically validated univariate
MultiRocket transform. Every transformed column can be decoded to its base
kernel, dilation, padding mode, bias, pooling operator, and signal
representation.
The recommended leakage-free workflow is implemented in ``irocket_model_selection``:
fit ``InterpRocketTransform`` inside each training partition, perform
resampled shrinkage-*t* consensus selection on that fixed feature universe,
measure selection reproducibility with the Nogueira statistic, and tune the
ridge classifier inside nested cross-validation.
Public surface
--------------
InterpRocketTransform
Classifier-agnostic MultiRocket transformer.
InterpRocket
Convenience transform-plus-ridge classifier.
compute_activation_map
Per-timepoint activation for a decoded kernel.
mutual_information
Confusion-matrix mutual information in bits.
OI, POOLING_COLORS, INFO_COLORS
Shared colorblind-safe plotting constants.
References
----------
Brunner, F. (2024). Explainable time series classification with X-ROCKET.
Tan, C. W., Dempster, A., Bergmeir, C., & Webb, G. I. (2022). MultiRocket.
Author
------
Mark Laubach, American University, Department of Neuroscience.
License
-------
BSD-3-Clause.
"""
import numpy as np
from itertools import combinations
from numba import njit, prange
import matplotlib.pyplot as plt
from sklearn.linear_model import RidgeClassifierCV
from sklearn.preprocessing import StandardScaler
from sklearn.base import BaseEstimator, ClassifierMixin, TransformerMixin
from sklearn.utils.validation import check_array, check_is_fitted
from sklearn.metrics import (
accuracy_score,
balanced_accuracy_score,
f1_score,
matthews_corrcoef,
confusion_matrix,
)
__all__ = [
"InterpRocketTransform",
"InterpRocket",
"compute_activation_map",
"mutual_information",
"OI",
"POOLING_COLORS",
"INFO_COLORS",
]
# ============================================================================
# COLORBLIND-SAFE PALETTES
# ============================================================================
# Okabe-Ito categorical palette shared by all categorical plots.
OI = [
"#0072B2", # blue
"#E69F00", # orange
"#009E73", # bluish green
"#D55E00", # vermillion
"#CC79A7", # reddish purple
"#56B4E9", # sky blue
"#F0E442", # yellow
"#000000", # black
]
# Pooling operators use the requested high-contrast Okabe-Ito subset.
POOLING_COLORS = {
"PPV": "#0072B2", # blue
"MPV": "#56B4E9", # sky blue
"MIPV": "#D55E00", # vermillion
"LSPV": "#E69F00", # orange
}
INFO_COLORS = {
"redundant": "#E69F00", # orange
"synergistic": "#0072B2", # blue
"independent": "#7f7f7f", # gray
}
def _validate_feature_index_array(feature_indices, n_features, *, name="feature_mask"):
"""Validate a one-dimensional, unique set of transformed feature indices."""
indices = np.asarray(feature_indices)
if indices.ndim != 1:
raise ValueError(f"{name} must be one-dimensional.")
if not np.issubdtype(indices.dtype, np.integer):
raise TypeError(f"{name} must contain integers.")
indices = indices.astype(np.int64, copy=False)
if indices.size == 0:
raise ValueError(f"{name} must contain at least one feature.")
if np.any(indices < 0) or np.any(indices >= int(n_features)):
raise ValueError(f"{name} contains an out-of-range feature index.")
if np.unique(indices).size != indices.size:
raise ValueError(f"{name} must not contain duplicate indices.")
return indices
def _kernel_configuration_key(feature_info):
"""Return the pre-bias convolutional kernel identity for one feature.
Multiple MultiRocket columns can share this identity while differing in
bias threshold and/or pooling operator. It is the appropriate grouping
key for displays whose unit is a unique convolutional kernel configuration
rather than an individual transformed feature.
"""
return (
str(feature_info["representation"]),
int(feature_info["kernel_index"]),
int(feature_info["dilation"]),
str(feature_info.get("padding_mode", "")),
)
def _format_feature_label(feature_info, *, compact=False):
"""Format a feature label that cannot hide distinct MultiRocket columns.
A complete feature identity includes representation, base kernel, dilation,
padding, bias threshold, and pooling operator. The transformed column index
and within-kernel bias rank are included so that two rows never appear to
describe the same feature when their thresholds differ.
"""
parts = []
if "feature_index" in feature_info:
parts.append(f"F{int(feature_info['feature_index'])}")
parts.append(f"K{int(feature_info['kernel_index'])}")
parts.append(f"d={int(feature_info['dilation'])}")
bias_rank = feature_info.get("bias_rank_within_kernel")
if bias_rank is not None:
parts.append(f"b{int(bias_rank)}")
parts.append(str(feature_info["pooling_op"]))
parts.append(str(feature_info["representation"]))
padding_mode = feature_info.get("padding_mode")
if padding_mode:
parts.append(
"S" if compact and padding_mode == "same"
else "V" if compact and padding_mode == "valid"
else str(padding_mode)
)
if not compact and "bias" in feature_info:
parts.append(f"bias={float(feature_info['bias']):.4g}")
return " ".join(parts)
# ============================================================================
# SECTION 1: THE 84 BASE KERNELS
# ============================================================================
#
# MiniRocket/MultiRocket use 84 deterministic kernels of length 9.
# Each kernel has weights from {-1, 2}: six positions get -1, three get 2.
# The 84 kernels enumerate all C(9,3) = 84 ways to choose which 3 of 9
# positions receive the weight 2 (the rest get -1).
# -------------------------------------------------------------------------
# Kernel core
# -------------------------------------------------------------------------
def _generate_base_kernels():
"""
Generate the 84 deterministic MiniRocket base kernels.
Returns
-------
kernels : ndarray, shape (84, 9), dtype float32
Each row is a length-9 kernel with weights in {-1, 2}.
indices : ndarray, shape (84, 3), dtype int32
The 3 positions (of 9) that receive weight 2 in each kernel.
"""
indices = np.array([combo for combo in combinations(range(9), 3)], dtype=np.int32)
kernels = np.full((84, 9), -1.0, dtype=np.float32)
for i, idx in enumerate(indices):
kernels[i, idx] = 2.0
return kernels, indices
def _fit_dilations(input_length, num_features, max_dilations_per_kernel):
"""
Determine dilations and features-per-dilation for given series length.
Follows the MiniRocket/MultiRocket algorithm exactly:
- max dilation = (input_length - 1) / (9 - 1), ensuring receptive field
fits within the series
- dilations are exponentially spaced: 2^0, 2^1, ..., 2^(num_dilations-1)
- features are distributed across dilations as evenly as possible
Parameters
----------
input_length : int
Length of input time series.
num_features : int
Target number of features (will be rounded to multiple of 84).
max_dilations_per_kernel : int
Maximum number of distinct dilations to use.
Returns
-------
dilations : ndarray of int32
The dilation values to use.
num_features_per_dilation : ndarray of int32
How many features (biases) to generate per dilation.
"""
if not isinstance(input_length, (int, np.integer)) or input_length < 9:
raise ValueError("input_length must be an integer of at least 9")
if not isinstance(num_features, (int, np.integer)) or num_features < 84:
raise ValueError("num_features must be an integer of at least 84")
if (
not isinstance(max_dilations_per_kernel, (int, np.integer))
or max_dilations_per_kernel < 1
):
raise ValueError("max_dilations_per_kernel must be a positive integer")
num_kernels = 84
num_features_per_kernel = num_features // num_kernels
true_max_dilations_per_kernel = min(
num_features_per_kernel, max_dilations_per_kernel
)
multiplier = num_features_per_kernel / true_max_dilations_per_kernel
# Canonical MiniRocket/MultiRocket allocation: start from an exponentially
# spaced grid, collapse duplicate integer dilations, scale their counts,
# then distribute any remainder so the requested per-kernel budget is
# preserved exactly.
max_exponent = np.log2((input_length - 1) / (9 - 1))
dilations, num_features_per_dilation = np.unique(
np.logspace(
0,
max_exponent,
true_max_dilations_per_kernel,
base=2,
).astype(np.int32),
return_counts=True,
)
num_features_per_dilation = (
num_features_per_dilation * multiplier
).astype(np.int32)
remainder = num_features_per_kernel - int(
np.sum(num_features_per_dilation)
)
i = 0
while remainder > 0:
num_features_per_dilation[i] += 1
remainder -= 1
i = (i + 1) % len(num_features_per_dilation)
return dilations.astype(np.int32), num_features_per_dilation.astype(np.int32)
def _quantiles(n):
"""Generate the canonical low-discrepancy MultiRocket quantiles.
The sequence is calculated in float64 and cast to float32 only after each
value has been generated. This matches the reference MultiRocket/aeon
implementation and avoids cumulative differences from a float32 golden
ratio constant.
"""
phi = (np.sqrt(5.0) + 1.0) / 2.0
return np.array(
[((i + 1) * phi) % 1.0 for i in range(n)],
dtype=np.float32,
)
@njit(fastmath=True, cache=True)
def _fit_biases(X, dilations, num_features_per_dilation, quantiles, random_state_seed):
"""Fit MultiRocket bias thresholds from the training data.
MultiRocket draws one training example independently for every
kernel--dilation combination, computes that combination's zero-padded
convolution output, and takes the assigned low-discrepancy quantiles from
that one output. Bias fitting always uses the padded convolution, even
though the transform alternates padded and unpadded pooling regions.
Parameters
----------
X : ndarray, shape (n_instances, n_timepoints), dtype float32
Training time series for one representation.
dilations : ndarray of int32
Dilation values.
num_features_per_dilation : ndarray of int32
Number of bias thresholds per kernel at each dilation.
quantiles : ndarray of float32
Low-discrepancy quantile positions, one per bias.
random_state_seed : int
Seed for the legacy NumPy random stream used by the reference
MultiRocket implementation.
Returns
-------
biases : ndarray of float32
Biases ordered by dilation, kernel, and within-combination quantile.
"""
np.random.seed(random_state_seed)
num_instances, input_length = X.shape
indices_raw = np.zeros((84, 3), dtype=np.int32)
count = 0
for i in range(9):
for j in range(i + 1, 9):
for k in range(j + 1, 9):
indices_raw[count, 0] = i
indices_raw[count, 1] = j
indices_raw[count, 2] = k
count += 1
num_kernels = 84
num_dilations = len(dilations)
num_features_total = num_kernels * np.sum(num_features_per_dilation)
biases = np.zeros(num_features_total, dtype=np.float32)
feature_index_start = 0
for dilation_index in range(num_dilations):
dilation = dilations[dilation_index]
padding = ((9 - 1) * dilation) // 2
num_features_this_dilation = num_features_per_dilation[dilation_index]
for kernel_index in range(num_kernels):
feature_index_end = feature_index_start + num_features_this_dilation
# One randomly selected training example per kernel--dilation pair.
x = X[np.random.randint(num_instances)]
A = -x
G = x + x + x
# Shared MiniRocket convolution construction.
C_alpha = np.zeros(input_length, dtype=np.float32)
C_alpha[:] = A
C_gamma = np.zeros((9, input_length), dtype=np.float32)
C_gamma[9 // 2] = G
shift_start = dilation
shift_end = input_length - padding
for gamma_index in range(9 // 2):
C_alpha[-shift_end:] = C_alpha[-shift_end:] + A[:shift_end]
C_gamma[gamma_index, -shift_end:] = G[:shift_end]
shift_end += dilation
for gamma_index in range(9 // 2 + 1, 9):
C_alpha[:-shift_start] = C_alpha[:-shift_start] + A[shift_start:]
C_gamma[gamma_index, :-shift_start] = G[shift_start:]
shift_start += dilation
i0 = indices_raw[kernel_index, 0]
i1 = indices_raw[kernel_index, 1]
i2 = indices_raw[kernel_index, 2]
C = C_alpha + C_gamma[i0] + C_gamma[i1] + C_gamma[i2]
biases[feature_index_start:feature_index_end] = np.quantile(
C,
quantiles[feature_index_start:feature_index_end],
)
feature_index_start = feature_index_end
return biases
@njit(fastmath=True, inline="always")
def _pool_convolution(C, bias, start, stop):
"""Return PPV, MPV, MIPV, and LSPV for one convolution region.
The implementation intentionally follows the reference MultiRocket source
and aeon, including its exact MPV and LSPV calculations. ``start`` is
inclusive and ``stop`` is exclusive.
"""
ppv = 0
last_val = 0
max_stretch = 0.0
mean_index = 0
mean = 0.0
n_values = stop - start
for local_index in range(n_values):
value = C[start + local_index]
if value > bias:
ppv += 1
mean_index += local_index
# This is the operation used in the original and aeon
# MultiRocket implementations.
mean += value + bias
elif value < bias:
stretch = local_index - last_val
if stretch > max_stretch:
max_stretch = stretch
last_val = local_index
stretch = n_values - 1 - last_val
if stretch > max_stretch:
max_stretch = stretch
ppv_value = ppv / n_values
mpv_value = mean / ppv if ppv > 0 else 0.0
mipv_value = mean_index / ppv if ppv > 0 else -1.0
return ppv_value, mpv_value, mipv_value, max_stretch
@njit(fastmath=True, parallel=True, cache=True)
def _transform(
X,
dilations,
num_features_per_dilation,
biases,
is_first_difference=False,
):
"""Apply one MultiRocket representation with transparent feature ordering.
Numerical calculations match the univariate reference implementation. The
only intentional difference is column order: I-ROCKET stores the four
pooling values contiguously for each bias as ``PPV, MPV, MIPV, LSPV`` so a
feature index can be decoded without a global column permutation.
Parameters
----------
X : ndarray, shape (n_instances, n_timepoints), dtype float32
Raw signals or first-differenced signals.
dilations, num_features_per_dilation, biases : ndarray
Fitted parameters for this representation.
is_first_difference : bool, default=False
Reproduce the reference MultiRocket alignment used when transforming
first differences. Bias fitting remains symmetric for both
representations, as in the reference implementation.
Returns
-------
features : ndarray, shape (n_instances, n_biases * 4)
Four contiguous pooling values per fitted bias.
"""
num_instances, input_length = X.shape
num_kernels = 84
num_dilations = len(dilations)
num_biases = num_kernels * np.sum(num_features_per_dilation)
features = np.zeros((num_instances, num_biases * 4), dtype=np.float32)
indices_raw = np.zeros((84, 3), dtype=np.int32)
count = 0
for i in range(9):
for j in range(i + 1, 9):
for k in range(j + 1, 9):
indices_raw[count, 0] = i
indices_raw[count, 1] = j
indices_raw[count, 2] = k
count += 1
for instance_index in prange(num_instances):
x = X[instance_index]
A = -x
G = x + x + x
feature_index_start = 0
for dilation_index in range(num_dilations):
padding_selector = dilation_index % 2
dilation = dilations[dilation_index]
padding = ((9 - 1) * dilation) // 2
num_features_this_dilation = num_features_per_dilation[dilation_index]
C_alpha = np.zeros(input_length, dtype=np.float32)
C_alpha[:] = A
C_gamma = np.zeros((9, input_length), dtype=np.float32)
C_gamma[9 // 2] = G
shift_start = dilation
# The original MultiRocket implementation uses the pre-difference
# length in this expression. For an already differenced array that
# is input_length + 1 and produces the established asymmetric
# alignment for positions left of the kernel center.
shift_end = input_length + (1 if is_first_difference else 0) - padding
for gamma_index in range(9 // 2):
C_alpha[-shift_end:] = C_alpha[-shift_end:] + A[:shift_end]
C_gamma[gamma_index, -shift_end:] = G[:shift_end]
shift_end += dilation
for gamma_index in range(9 // 2 + 1, 9):
C_alpha[:-shift_start] = C_alpha[:-shift_start] + A[shift_start:]
C_gamma[gamma_index, :-shift_start] = G[shift_start:]
shift_start += dilation
for kernel_index in range(num_kernels):
feature_index_end = feature_index_start + num_features_this_dilation
uses_same_padding = ((padding_selector + kernel_index) % 2) == 0
i0 = indices_raw[kernel_index, 0]
i1 = indices_raw[kernel_index, 1]
i2 = indices_raw[kernel_index, 2]
C = C_alpha + C_gamma[i0] + C_gamma[i1] + C_gamma[i2]
if uses_same_padding:
pool_start = 0
pool_stop = C.shape[0]
else:
pool_start = padding
pool_stop = C.shape[0] - padding
for feature_count in range(num_features_this_dilation):
feature_index = feature_index_start + feature_count
bias = biases[feature_index]
ppv, mpv, mipv, lspv = _pool_convolution(
C,
bias,
pool_start,
pool_stop,
)
output_index = feature_index * 4
features[instance_index, output_index] = ppv
features[instance_index, output_index + 1] = mpv
features[instance_index, output_index + 2] = mipv
features[instance_index, output_index + 3] = lspv
feature_index_start = feature_index_end
return features
@njit(fastmath=True, cache=True)
def _compute_activation_map_core(
x,
kernel_index,
dilation,
bias,
uses_same_padding,
is_first_difference,
):
input_length = len(x)
padding = ((9 - 1) * dilation) // 2
indices_raw = np.zeros((84, 3), dtype=np.int32)
count = 0
for i in range(9):
for j in range(i + 1, 9):
for k in range(j + 1, 9):
indices_raw[count, 0] = i
indices_raw[count, 1] = j
indices_raw[count, 2] = k
count += 1
kernel = np.full(9, -1.0, dtype=np.float32)
i0 = indices_raw[kernel_index, 0]
i1 = indices_raw[kernel_index, 1]
i2 = indices_raw[kernel_index, 2]
kernel[i0] = 2.0
kernel[i1] = 2.0
kernel[i2] = 2.0
offsets = np.empty(9, dtype=np.int32)
for position in range(9):
if is_first_difference and position < 4:
offsets[position] = (position - 4) * dilation + 1
else:
offsets[position] = (position - 4) * dilation
full_conv = np.zeros(input_length, dtype=np.float32)
for output_index in range(input_length):
value = np.float32(0.0)
for position in range(9):
input_index = output_index + offsets[position]
if 0 <= input_index < input_length:
value += kernel[position] * x[input_index]
full_conv[output_index] = value
if uses_same_padding:
start = 0
stop = input_length
else:
start = padding
stop = input_length - padding
n_output = stop - start
conv_output = np.zeros(n_output, dtype=np.float32)
activation = np.zeros(n_output, dtype=np.float32)
time_indices = np.zeros(n_output, dtype=np.float32)
for local_index in range(n_output):
full_index = start + local_index
conv_value = full_conv[full_index]
conv_output[local_index] = conv_value
activation[local_index] = 1.0 if conv_value > bias else 0.0
time_indices[local_index] = full_index
return conv_output, activation, time_indices
def compute_activation_map(
x,
kernel_index,
dilation,
bias,
padding="same",
representation="raw",
):
"""Compute the convolution and binary activation for one decoded feature.
Parameters
----------
x : ndarray, shape (n_timepoints,)
A raw signal when ``representation='raw'`` or an already
first-differenced signal when ``representation='diff'``.
kernel_index : int
Index of one of the 84 deterministic kernels.
dilation : int
Kernel dilation.
bias : float
Fitted bias threshold.
padding : {'same', 'valid'}, default='same'
Pooling region used by the decoded feature.
representation : {'raw', 'diff'}, default='raw'
Selects the established MultiRocket convolution alignment.
Returns
-------
conv_output, activation, time_indices : ndarray
Values from the exact region used by the feature. ``time_indices``
refer to indices in ``x``; callers mapping differences back to the
original signal may apply their preferred half-sample convention.
"""
x = np.asarray(x, dtype=np.float32)
if x.ndim != 1:
raise ValueError("x must be one-dimensional")
if not np.isfinite(x).all():
raise ValueError("x must contain only finite values")
if not isinstance(kernel_index, (int, np.integer)) or not 0 <= kernel_index < 84:
raise ValueError("kernel_index must be an integer from 0 through 83")
if not isinstance(dilation, (int, np.integer)) or dilation < 1:
raise ValueError("dilation must be a positive integer")
if padding not in ("same", "valid"):
raise ValueError("padding must be 'same' or 'valid'")
if representation not in ("raw", "diff"):
raise ValueError("representation must be 'raw' or 'diff'")
required_length = 1 + 8 * int(dilation)
if padding == "valid" and len(x) < required_length:
raise ValueError(
"x is too short for valid convolution at the requested dilation"
)
return _compute_activation_map_core(
x,
int(kernel_index),
int(dilation),
np.float32(bias),
padding == "same",
representation == "diff",
)
def mutual_information(y_true=None, y_pred=None, cm=None, base=2):
"""
Calculate mutual information between true and predicted labels.
Parameters
----------
y_true : array-like, optional
True class labels.
y_pred : array-like, optional
Predicted class labels.
cm : array-like, optional
Pre-computed confusion matrix (rows=true, cols=predicted).
base : int or float, default=2
Logarithm base. Use 2 for bits, np.e for nats.
Returns
-------
mi : float
Mutual information in specified units (bits if base=2).
"""
if cm is None:
if y_true is None or y_pred is None:
raise ValueError("Must provide either (y_true, y_pred) or cm")
cm = confusion_matrix(y_true, y_pred)
else:
cm = np.asarray(cm)
total = cm.sum()
if total == 0:
return 0.0
p_joint = cm / total
p_true = p_joint.sum(axis=1)
p_pred = p_joint.sum(axis=0)
mi = 0.0
n_classes_true, n_classes_pred = p_joint.shape
for i in range(n_classes_true):
for j in range(n_classes_pred):
if p_joint[i, j] > 0 and p_true[i] > 0 and p_pred[j] > 0:
mi += (
p_joint[i, j]
* np.log(p_joint[i, j] / (p_true[i] * p_pred[j]))
/ np.log(base)
)
return mi
def _compute_all_metrics(y_true, y_pred):
"""
Compute all classification metrics.
Returns
-------
metrics : dict with keys:
'accuracy', 'balanced_accuracy', 'f1_macro', 'f1_weighted',
'mcc', 'mutual_info'
"""
y_true = np.asarray(y_true)
y_pred = np.asarray(y_pred)
n_classes = len(np.unique(y_true))
avg = "binary" if n_classes == 2 else "macro"
return {
"accuracy": float(accuracy_score(y_true, y_pred)),
"balanced_accuracy": float(balanced_accuracy_score(y_true, y_pred)),
"f1_macro": float(f1_score(y_true, y_pred, average="macro", zero_division=0)),
"f1_weighted": float(
f1_score(y_true, y_pred, average="weighted", zero_division=0)
),
"mcc": float(matthews_corrcoef(y_true, y_pred)),
"mutual_info": float(mutual_information(y_true=y_true, y_pred=y_pred)),
}
# -------------------------------------------------------------------------
# InterpRocket
# -------------------------------------------------------------------------
class InterpRocketTransform(TransformerMixin, BaseEstimator):
"""Transparent univariate MultiRocket feature transformer.
This estimator fits only the unsupervised convolutional transform. It is
the component intended for scikit-learn pipelines and nested validation:
the transform is fitted on each training partition, and downstream
selectors and classifiers operate on the resulting feature matrix.
Parameters
----------
max_dilations_per_kernel : int, default=16
Maximum number of dilation values per kernel.
num_features : int, default=10000
Target number of bias features per representation. MultiRocket emits
four pooling values per bias and rounds the bias budget down to a
multiple of the 84 deterministic kernels.
random_state : int, default=0
Seed used for the training-example draws in bias fitting.
representations : {'both', 'raw', 'diff'}, default='both'
Signal representations included in the transform.
verbose : bool or int, default=False
Print fit progress when truthy.
"""
POOLING_NAMES = ["PPV", "MPV", "MIPV", "LSPV"]
_TRANSFORM_LEARNED_ATTRIBUTES = (
"n_features_in_",
"n_timepoints_in_",
"base_kernels_",
"base_indices_",
"dilations_raw_",
"num_features_per_dilation_raw_",
"biases_raw_",
"dilations_diff_",
"num_features_per_dilation_diff_",
"biases_diff_",
"n_features_per_rep_",
"n_output_features_",
"_n_features_out",
)
def __init__(
self,
max_dilations_per_kernel=16,
num_features=10000,
random_state=0,
representations="both",
verbose=False,
):
self.max_dilations_per_kernel = max_dilations_per_kernel
self.num_features = num_features
self.random_state = random_state
self.representations = representations
self.verbose = verbose
def _reset_transform_state(self):
"""Remove learned transform state before refitting."""
for attribute in self._TRANSFORM_LEARNED_ATTRIBUTES:
if hasattr(self, attribute):
delattr(self, attribute)
def _validate_parameters(self):
if self.representations not in ("both", "raw", "diff"):
raise ValueError(
"representations must be 'both', 'raw', or 'diff', "
f"got {self.representations!r}"
)
if (
isinstance(self.max_dilations_per_kernel, (bool, np.bool_))
or not isinstance(
self.max_dilations_per_kernel, (int, np.integer)
)
or self.max_dilations_per_kernel < 1
):
raise ValueError(
"max_dilations_per_kernel must be a positive integer"
)
if (
isinstance(self.num_features, (bool, np.bool_))
or not isinstance(self.num_features, (int, np.integer))
or self.num_features < 84
):
raise ValueError("num_features must be an integer of at least 84")
if isinstance(self.random_state, (bool, np.bool_)) or not isinstance(
self.random_state, (int, np.integer)
):
raise ValueError("random_state must be an integer")
if not isinstance(self.verbose, (bool, np.bool_, int, np.integer)):
raise ValueError("verbose must be a boolean or integer")
def _validate_X(self, X, *, reset):
X = check_array(
X,
accept_sparse=False,
ensure_2d=True,
allow_nd=False,
dtype=np.float32,
)
minimum_length = 10 if self.representations in ("both", "diff") else 9
if X.shape[1] < minimum_length:
if minimum_length == 10:
raise ValueError(
"at least 10 timepoints are required when first "
"differences are used"
)
raise ValueError("at least 9 timepoints are required")
if reset:
self.n_features_in_ = X.shape[1]
self.n_timepoints_in_ = X.shape[1]
elif X.shape[1] != self.n_timepoints_in_:
raise ValueError(
"X has a different number of timepoints than the data used "
f"during fit: got {X.shape[1]}, expected "
f"{self.n_timepoints_in_}"
)
return X
def _log(self, message):
if self.verbose:
print(message)
def _fit_transform_parameters(self, X):
"""Fit kernels, dilations, and biases on a validated matrix."""
n_instances, input_length = X.shape
self._log(
f"{self.__class__.__name__}.fit: {n_instances} instances x "
f"{input_length} timepoints"
)
self.base_kernels_, self.base_indices_ = _generate_base_kernels()
use_raw = self.representations in ("both", "raw")
use_diff = self.representations in ("both", "diff")
# Define all representation attributes on every successful fit. This
# prevents stale parameters when an estimator is refit after changing
# ``representations`` with ``set_params``.
self.dilations_raw_ = np.empty(0, dtype=np.int32)
self.num_features_per_dilation_raw_ = np.empty(0, dtype=np.int32)
self.biases_raw_ = np.empty(0, dtype=np.float32)
self.dilations_diff_ = np.empty(0, dtype=np.int32)
self.num_features_per_dilation_diff_ = np.empty(0, dtype=np.int32)
self.biases_diff_ = np.empty(0, dtype=np.float32)
if use_raw:
self._log(" Fitting dilations (raw)...")
(
self.dilations_raw_,
self.num_features_per_dilation_raw_,
) = _fit_dilations(
input_length,
self.num_features,
self.max_dilations_per_kernel,
)
n_features_raw = 84 * int(
np.sum(self.num_features_per_dilation_raw_)
)
self._log(
f" Fitting biases (raw): {n_features_raw} biases across "
f"{len(self.dilations_raw_)} dilations..."
)
self.biases_raw_ = _fit_biases(
X,
self.dilations_raw_,
self.num_features_per_dilation_raw_,
_quantiles(n_features_raw),
self.random_state,
)
else:
n_features_raw = 0
if use_diff:
X_diff = np.diff(X, axis=1).astype(np.float32)
self._log(" Fitting dilations (diff)...")
(
self.dilations_diff_,
self.num_features_per_dilation_diff_,
) = _fit_dilations(
X_diff.shape[1],
self.num_features,
self.max_dilations_per_kernel,
)
n_features_diff = 84 * int(
np.sum(self.num_features_per_dilation_diff_)
)
self._log(
f" Fitting biases (diff): {n_features_diff} biases across "
f"{len(self.dilations_diff_)} dilations..."
)
self.biases_diff_ = _fit_biases(
X_diff,
self.dilations_diff_,
self.num_features_per_dilation_diff_,
_quantiles(n_features_diff),
self.random_state,
)
else:
n_features_diff = 0
self.n_features_per_rep_ = (
int(n_features_raw),
int(n_features_diff),
)
self.n_output_features_ = int(4 * (n_features_raw + n_features_diff))
self._n_features_out = self.n_output_features_
def fit(self, X, y=None):
"""Fit the convolutional transform; ``y`` is accepted and ignored."""
self._reset_transform_state()
self._validate_parameters()
X = self._validate_X(X, reset=True)
self._fit_transform_parameters(X)
return self
def _transform(self, X):
"""Apply the fitted raw and/or differenced MultiRocket transform."""
check_is_fitted(
self,
attributes=[
"base_kernels_",
"base_indices_",
"n_features_per_rep_",
"n_timepoints_in_",
],
)
X = self._validate_X(X, reset=False)
blocks = []
if self.representations in ("both", "raw"):
blocks.append(
_transform(
X,
self.dilations_raw_,
self.num_features_per_dilation_raw_,
self.biases_raw_,
)
)
if self.representations in ("both", "diff"):
blocks.append(
_transform(
np.diff(X, axis=1).astype(np.float32),
self.dilations_diff_,
self.num_features_per_dilation_diff_,
self.biases_diff_,
is_first_difference=True,
)
)
return blocks[0] if len(blocks) == 1 else np.concatenate(blocks, axis=1)
def transform(self, X):