msrishav commited on
Commit
e68eb1c
·
verified ·
1 Parent(s): 3064d0c

Add inference code, config, and technical report

Browse files
config/folds.json ADDED
@@ -0,0 +1,1057 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "dataset_sha256": "79c14580e40baa6c0d08faf84d3b754e92be28aaf80fc6be4a3b341ea35a0544",
3
+ "final_holdout_fold": 4,
4
+ "final_holdout_seq_ids": [
5
+ 1,
6
+ 13,
7
+ 20,
8
+ 21,
9
+ 34,
10
+ 43,
11
+ 48,
12
+ 49,
13
+ 50,
14
+ 52,
15
+ 53,
16
+ 54,
17
+ 58,
18
+ 71,
19
+ 80,
20
+ 87,
21
+ 88,
22
+ 91,
23
+ 99,
24
+ 102,
25
+ 105,
26
+ 106,
27
+ 121,
28
+ 130,
29
+ 134,
30
+ 149,
31
+ 151,
32
+ 160,
33
+ 161,
34
+ 166,
35
+ 169,
36
+ 174,
37
+ 187,
38
+ 188,
39
+ 189,
40
+ 190,
41
+ 191,
42
+ 201,
43
+ 205,
44
+ 214,
45
+ 217,
46
+ 235,
47
+ 241,
48
+ 243,
49
+ 251,
50
+ 252,
51
+ 257,
52
+ 259,
53
+ 263,
54
+ 264,
55
+ 269,
56
+ 270,
57
+ 273,
58
+ 276,
59
+ 293,
60
+ 303,
61
+ 306,
62
+ 308,
63
+ 309,
64
+ 313,
65
+ 315,
66
+ 319,
67
+ 328,
68
+ 330,
69
+ 339,
70
+ 343,
71
+ 344,
72
+ 345,
73
+ 350,
74
+ 359,
75
+ 363,
76
+ 366,
77
+ 372,
78
+ 385,
79
+ 387,
80
+ 389,
81
+ 401,
82
+ 413,
83
+ 419,
84
+ 427,
85
+ 430,
86
+ 435,
87
+ 443,
88
+ 445,
89
+ 455,
90
+ 458,
91
+ 459,
92
+ 463,
93
+ 466,
94
+ 470,
95
+ 471,
96
+ 472,
97
+ 474,
98
+ 475,
99
+ 488,
100
+ 490,
101
+ 491,
102
+ 498,
103
+ 500,
104
+ 506,
105
+ 510,
106
+ 514,
107
+ 516
108
+ ],
109
+ "folds": {
110
+ "0": [
111
+ 0,
112
+ 2,
113
+ 9,
114
+ 10,
115
+ 11,
116
+ 18,
117
+ 22,
118
+ 30,
119
+ 33,
120
+ 39,
121
+ 46,
122
+ 55,
123
+ 63,
124
+ 68,
125
+ 69,
126
+ 70,
127
+ 72,
128
+ 73,
129
+ 76,
130
+ 77,
131
+ 78,
132
+ 79,
133
+ 82,
134
+ 84,
135
+ 90,
136
+ 93,
137
+ 101,
138
+ 104,
139
+ 124,
140
+ 131,
141
+ 132,
142
+ 137,
143
+ 140,
144
+ 148,
145
+ 153,
146
+ 155,
147
+ 167,
148
+ 172,
149
+ 176,
150
+ 177,
151
+ 184,
152
+ 194,
153
+ 195,
154
+ 203,
155
+ 204,
156
+ 208,
157
+ 209,
158
+ 220,
159
+ 222,
160
+ 225,
161
+ 227,
162
+ 231,
163
+ 237,
164
+ 238,
165
+ 244,
166
+ 247,
167
+ 249,
168
+ 250,
169
+ 253,
170
+ 275,
171
+ 277,
172
+ 281,
173
+ 287,
174
+ 289,
175
+ 296,
176
+ 298,
177
+ 304,
178
+ 317,
179
+ 318,
180
+ 320,
181
+ 323,
182
+ 324,
183
+ 325,
184
+ 331,
185
+ 341,
186
+ 342,
187
+ 353,
188
+ 357,
189
+ 362,
190
+ 382,
191
+ 398,
192
+ 400,
193
+ 403,
194
+ 405,
195
+ 426,
196
+ 429,
197
+ 431,
198
+ 441,
199
+ 447,
200
+ 451,
201
+ 454,
202
+ 465,
203
+ 469,
204
+ 477,
205
+ 487,
206
+ 489,
207
+ 501,
208
+ 503,
209
+ 504,
210
+ 507,
211
+ 508,
212
+ 509,
213
+ 512,
214
+ 513
215
+ ],
216
+ "1": [
217
+ 3,
218
+ 5,
219
+ 7,
220
+ 15,
221
+ 16,
222
+ 17,
223
+ 19,
224
+ 23,
225
+ 24,
226
+ 25,
227
+ 26,
228
+ 29,
229
+ 31,
230
+ 37,
231
+ 42,
232
+ 45,
233
+ 56,
234
+ 57,
235
+ 60,
236
+ 66,
237
+ 67,
238
+ 75,
239
+ 86,
240
+ 94,
241
+ 108,
242
+ 110,
243
+ 113,
244
+ 114,
245
+ 116,
246
+ 117,
247
+ 118,
248
+ 126,
249
+ 141,
250
+ 144,
251
+ 154,
252
+ 157,
253
+ 168,
254
+ 173,
255
+ 175,
256
+ 180,
257
+ 181,
258
+ 182,
259
+ 185,
260
+ 192,
261
+ 210,
262
+ 211,
263
+ 218,
264
+ 228,
265
+ 234,
266
+ 255,
267
+ 265,
268
+ 266,
269
+ 268,
270
+ 271,
271
+ 272,
272
+ 274,
273
+ 278,
274
+ 284,
275
+ 286,
276
+ 299,
277
+ 302,
278
+ 311,
279
+ 312,
280
+ 321,
281
+ 329,
282
+ 332,
283
+ 334,
284
+ 340,
285
+ 349,
286
+ 352,
287
+ 356,
288
+ 367,
289
+ 368,
290
+ 373,
291
+ 376,
292
+ 380,
293
+ 384,
294
+ 391,
295
+ 393,
296
+ 396,
297
+ 397,
298
+ 408,
299
+ 409,
300
+ 412,
301
+ 416,
302
+ 420,
303
+ 421,
304
+ 422,
305
+ 433,
306
+ 438,
307
+ 446,
308
+ 452,
309
+ 456,
310
+ 457,
311
+ 467,
312
+ 468,
313
+ 479,
314
+ 483,
315
+ 485,
316
+ 496,
317
+ 497,
318
+ 505,
319
+ 511,
320
+ 515
321
+ ],
322
+ "2": [
323
+ 6,
324
+ 36,
325
+ 38,
326
+ 59,
327
+ 74,
328
+ 81,
329
+ 83,
330
+ 89,
331
+ 92,
332
+ 96,
333
+ 97,
334
+ 103,
335
+ 109,
336
+ 111,
337
+ 112,
338
+ 119,
339
+ 122,
340
+ 123,
341
+ 125,
342
+ 129,
343
+ 139,
344
+ 143,
345
+ 145,
346
+ 146,
347
+ 147,
348
+ 150,
349
+ 152,
350
+ 158,
351
+ 163,
352
+ 165,
353
+ 179,
354
+ 183,
355
+ 193,
356
+ 196,
357
+ 198,
358
+ 199,
359
+ 202,
360
+ 219,
361
+ 223,
362
+ 229,
363
+ 233,
364
+ 239,
365
+ 245,
366
+ 246,
367
+ 248,
368
+ 261,
369
+ 262,
370
+ 280,
371
+ 290,
372
+ 291,
373
+ 294,
374
+ 297,
375
+ 301,
376
+ 305,
377
+ 307,
378
+ 310,
379
+ 314,
380
+ 316,
381
+ 322,
382
+ 335,
383
+ 336,
384
+ 338,
385
+ 346,
386
+ 347,
387
+ 348,
388
+ 354,
389
+ 360,
390
+ 361,
391
+ 364,
392
+ 365,
393
+ 370,
394
+ 371,
395
+ 375,
396
+ 377,
397
+ 386,
398
+ 388,
399
+ 392,
400
+ 394,
401
+ 402,
402
+ 404,
403
+ 406,
404
+ 407,
405
+ 411,
406
+ 417,
407
+ 418,
408
+ 428,
409
+ 432,
410
+ 440,
411
+ 442,
412
+ 450,
413
+ 453,
414
+ 461,
415
+ 464,
416
+ 473,
417
+ 481,
418
+ 482,
419
+ 484,
420
+ 486,
421
+ 492,
422
+ 493,
423
+ 495,
424
+ 499,
425
+ 502
426
+ ],
427
+ "3": [
428
+ 4,
429
+ 8,
430
+ 12,
431
+ 14,
432
+ 27,
433
+ 28,
434
+ 32,
435
+ 35,
436
+ 40,
437
+ 41,
438
+ 44,
439
+ 47,
440
+ 51,
441
+ 61,
442
+ 62,
443
+ 64,
444
+ 65,
445
+ 85,
446
+ 95,
447
+ 98,
448
+ 100,
449
+ 107,
450
+ 115,
451
+ 120,
452
+ 127,
453
+ 128,
454
+ 133,
455
+ 135,
456
+ 136,
457
+ 138,
458
+ 142,
459
+ 156,
460
+ 159,
461
+ 162,
462
+ 164,
463
+ 170,
464
+ 171,
465
+ 178,
466
+ 186,
467
+ 197,
468
+ 200,
469
+ 206,
470
+ 207,
471
+ 212,
472
+ 213,
473
+ 215,
474
+ 216,
475
+ 221,
476
+ 224,
477
+ 226,
478
+ 230,
479
+ 232,
480
+ 236,
481
+ 240,
482
+ 242,
483
+ 254,
484
+ 256,
485
+ 258,
486
+ 260,
487
+ 267,
488
+ 279,
489
+ 282,
490
+ 283,
491
+ 285,
492
+ 288,
493
+ 292,
494
+ 295,
495
+ 300,
496
+ 326,
497
+ 327,
498
+ 333,
499
+ 337,
500
+ 351,
501
+ 355,
502
+ 358,
503
+ 369,
504
+ 374,
505
+ 378,
506
+ 379,
507
+ 381,
508
+ 383,
509
+ 390,
510
+ 395,
511
+ 399,
512
+ 410,
513
+ 414,
514
+ 415,
515
+ 423,
516
+ 424,
517
+ 425,
518
+ 434,
519
+ 436,
520
+ 437,
521
+ 439,
522
+ 444,
523
+ 448,
524
+ 449,
525
+ 460,
526
+ 462,
527
+ 476,
528
+ 478,
529
+ 480,
530
+ 494
531
+ ],
532
+ "4": [
533
+ 1,
534
+ 13,
535
+ 20,
536
+ 21,
537
+ 34,
538
+ 43,
539
+ 48,
540
+ 49,
541
+ 50,
542
+ 52,
543
+ 53,
544
+ 54,
545
+ 58,
546
+ 71,
547
+ 80,
548
+ 87,
549
+ 88,
550
+ 91,
551
+ 99,
552
+ 102,
553
+ 105,
554
+ 106,
555
+ 121,
556
+ 130,
557
+ 134,
558
+ 149,
559
+ 151,
560
+ 160,
561
+ 161,
562
+ 166,
563
+ 169,
564
+ 174,
565
+ 187,
566
+ 188,
567
+ 189,
568
+ 190,
569
+ 191,
570
+ 201,
571
+ 205,
572
+ 214,
573
+ 217,
574
+ 235,
575
+ 241,
576
+ 243,
577
+ 251,
578
+ 252,
579
+ 257,
580
+ 259,
581
+ 263,
582
+ 264,
583
+ 269,
584
+ 270,
585
+ 273,
586
+ 276,
587
+ 293,
588
+ 303,
589
+ 306,
590
+ 308,
591
+ 309,
592
+ 313,
593
+ 315,
594
+ 319,
595
+ 328,
596
+ 330,
597
+ 339,
598
+ 343,
599
+ 344,
600
+ 345,
601
+ 350,
602
+ 359,
603
+ 363,
604
+ 366,
605
+ 372,
606
+ 385,
607
+ 387,
608
+ 389,
609
+ 401,
610
+ 413,
611
+ 419,
612
+ 427,
613
+ 430,
614
+ 435,
615
+ 443,
616
+ 445,
617
+ 455,
618
+ 458,
619
+ 459,
620
+ 463,
621
+ 466,
622
+ 470,
623
+ 471,
624
+ 472,
625
+ 474,
626
+ 475,
627
+ 488,
628
+ 490,
629
+ 491,
630
+ 498,
631
+ 500,
632
+ 506,
633
+ 510,
634
+ 514,
635
+ 516
636
+ ]
637
+ },
638
+ "git_commit": "cf46f28c2a9df775367b428eedd3efd5a2f68399",
639
+ "n_folds": 5,
640
+ "seed": 42,
641
+ "train_dev_seq_ids": [
642
+ 0,
643
+ 2,
644
+ 3,
645
+ 4,
646
+ 5,
647
+ 6,
648
+ 7,
649
+ 8,
650
+ 9,
651
+ 10,
652
+ 11,
653
+ 12,
654
+ 14,
655
+ 15,
656
+ 16,
657
+ 17,
658
+ 18,
659
+ 19,
660
+ 22,
661
+ 23,
662
+ 24,
663
+ 25,
664
+ 26,
665
+ 27,
666
+ 28,
667
+ 29,
668
+ 30,
669
+ 31,
670
+ 32,
671
+ 33,
672
+ 35,
673
+ 36,
674
+ 37,
675
+ 38,
676
+ 39,
677
+ 40,
678
+ 41,
679
+ 42,
680
+ 44,
681
+ 45,
682
+ 46,
683
+ 47,
684
+ 51,
685
+ 55,
686
+ 56,
687
+ 57,
688
+ 59,
689
+ 60,
690
+ 61,
691
+ 62,
692
+ 63,
693
+ 64,
694
+ 65,
695
+ 66,
696
+ 67,
697
+ 68,
698
+ 69,
699
+ 70,
700
+ 72,
701
+ 73,
702
+ 74,
703
+ 75,
704
+ 76,
705
+ 77,
706
+ 78,
707
+ 79,
708
+ 81,
709
+ 82,
710
+ 83,
711
+ 84,
712
+ 85,
713
+ 86,
714
+ 89,
715
+ 90,
716
+ 92,
717
+ 93,
718
+ 94,
719
+ 95,
720
+ 96,
721
+ 97,
722
+ 98,
723
+ 100,
724
+ 101,
725
+ 103,
726
+ 104,
727
+ 107,
728
+ 108,
729
+ 109,
730
+ 110,
731
+ 111,
732
+ 112,
733
+ 113,
734
+ 114,
735
+ 115,
736
+ 116,
737
+ 117,
738
+ 118,
739
+ 119,
740
+ 120,
741
+ 122,
742
+ 123,
743
+ 124,
744
+ 125,
745
+ 126,
746
+ 127,
747
+ 128,
748
+ 129,
749
+ 131,
750
+ 132,
751
+ 133,
752
+ 135,
753
+ 136,
754
+ 137,
755
+ 138,
756
+ 139,
757
+ 140,
758
+ 141,
759
+ 142,
760
+ 143,
761
+ 144,
762
+ 145,
763
+ 146,
764
+ 147,
765
+ 148,
766
+ 150,
767
+ 152,
768
+ 153,
769
+ 154,
770
+ 155,
771
+ 156,
772
+ 157,
773
+ 158,
774
+ 159,
775
+ 162,
776
+ 163,
777
+ 164,
778
+ 165,
779
+ 167,
780
+ 168,
781
+ 170,
782
+ 171,
783
+ 172,
784
+ 173,
785
+ 175,
786
+ 176,
787
+ 177,
788
+ 178,
789
+ 179,
790
+ 180,
791
+ 181,
792
+ 182,
793
+ 183,
794
+ 184,
795
+ 185,
796
+ 186,
797
+ 192,
798
+ 193,
799
+ 194,
800
+ 195,
801
+ 196,
802
+ 197,
803
+ 198,
804
+ 199,
805
+ 200,
806
+ 202,
807
+ 203,
808
+ 204,
809
+ 206,
810
+ 207,
811
+ 208,
812
+ 209,
813
+ 210,
814
+ 211,
815
+ 212,
816
+ 213,
817
+ 215,
818
+ 216,
819
+ 218,
820
+ 219,
821
+ 220,
822
+ 221,
823
+ 222,
824
+ 223,
825
+ 224,
826
+ 225,
827
+ 226,
828
+ 227,
829
+ 228,
830
+ 229,
831
+ 230,
832
+ 231,
833
+ 232,
834
+ 233,
835
+ 234,
836
+ 236,
837
+ 237,
838
+ 238,
839
+ 239,
840
+ 240,
841
+ 242,
842
+ 244,
843
+ 245,
844
+ 246,
845
+ 247,
846
+ 248,
847
+ 249,
848
+ 250,
849
+ 253,
850
+ 254,
851
+ 255,
852
+ 256,
853
+ 258,
854
+ 260,
855
+ 261,
856
+ 262,
857
+ 265,
858
+ 266,
859
+ 267,
860
+ 268,
861
+ 271,
862
+ 272,
863
+ 274,
864
+ 275,
865
+ 277,
866
+ 278,
867
+ 279,
868
+ 280,
869
+ 281,
870
+ 282,
871
+ 283,
872
+ 284,
873
+ 285,
874
+ 286,
875
+ 287,
876
+ 288,
877
+ 289,
878
+ 290,
879
+ 291,
880
+ 292,
881
+ 294,
882
+ 295,
883
+ 296,
884
+ 297,
885
+ 298,
886
+ 299,
887
+ 300,
888
+ 301,
889
+ 302,
890
+ 304,
891
+ 305,
892
+ 307,
893
+ 310,
894
+ 311,
895
+ 312,
896
+ 314,
897
+ 316,
898
+ 317,
899
+ 318,
900
+ 320,
901
+ 321,
902
+ 322,
903
+ 323,
904
+ 324,
905
+ 325,
906
+ 326,
907
+ 327,
908
+ 329,
909
+ 331,
910
+ 332,
911
+ 333,
912
+ 334,
913
+ 335,
914
+ 336,
915
+ 337,
916
+ 338,
917
+ 340,
918
+ 341,
919
+ 342,
920
+ 346,
921
+ 347,
922
+ 348,
923
+ 349,
924
+ 351,
925
+ 352,
926
+ 353,
927
+ 354,
928
+ 355,
929
+ 356,
930
+ 357,
931
+ 358,
932
+ 360,
933
+ 361,
934
+ 362,
935
+ 364,
936
+ 365,
937
+ 367,
938
+ 368,
939
+ 369,
940
+ 370,
941
+ 371,
942
+ 373,
943
+ 374,
944
+ 375,
945
+ 376,
946
+ 377,
947
+ 378,
948
+ 379,
949
+ 380,
950
+ 381,
951
+ 382,
952
+ 383,
953
+ 384,
954
+ 386,
955
+ 388,
956
+ 390,
957
+ 391,
958
+ 392,
959
+ 393,
960
+ 394,
961
+ 395,
962
+ 396,
963
+ 397,
964
+ 398,
965
+ 399,
966
+ 400,
967
+ 402,
968
+ 403,
969
+ 404,
970
+ 405,
971
+ 406,
972
+ 407,
973
+ 408,
974
+ 409,
975
+ 410,
976
+ 411,
977
+ 412,
978
+ 414,
979
+ 415,
980
+ 416,
981
+ 417,
982
+ 418,
983
+ 420,
984
+ 421,
985
+ 422,
986
+ 423,
987
+ 424,
988
+ 425,
989
+ 426,
990
+ 428,
991
+ 429,
992
+ 431,
993
+ 432,
994
+ 433,
995
+ 434,
996
+ 436,
997
+ 437,
998
+ 438,
999
+ 439,
1000
+ 440,
1001
+ 441,
1002
+ 442,
1003
+ 444,
1004
+ 446,
1005
+ 447,
1006
+ 448,
1007
+ 449,
1008
+ 450,
1009
+ 451,
1010
+ 452,
1011
+ 453,
1012
+ 454,
1013
+ 456,
1014
+ 457,
1015
+ 460,
1016
+ 461,
1017
+ 462,
1018
+ 464,
1019
+ 465,
1020
+ 467,
1021
+ 468,
1022
+ 469,
1023
+ 473,
1024
+ 476,
1025
+ 477,
1026
+ 478,
1027
+ 479,
1028
+ 480,
1029
+ 481,
1030
+ 482,
1031
+ 483,
1032
+ 484,
1033
+ 485,
1034
+ 486,
1035
+ 487,
1036
+ 489,
1037
+ 492,
1038
+ 493,
1039
+ 494,
1040
+ 495,
1041
+ 496,
1042
+ 497,
1043
+ 499,
1044
+ 501,
1045
+ 502,
1046
+ 503,
1047
+ 504,
1048
+ 505,
1049
+ 507,
1050
+ 508,
1051
+ 509,
1052
+ 511,
1053
+ 512,
1054
+ 513,
1055
+ 515
1056
+ ]
1057
+ }
reports/phase2_technical_report.md ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Wunder Fund RNN Challenge — Technical Report
2
+
3
+ **Task.** Predict the next 32-dimensional market state from a sequence of prior
4
+ states, scored online (`PredictionModel.predict` one row at a time) by mean
5
+ per-feature R². Inference budget: 1 CPU core, 16 GB RAM, no GPU, 60 minutes,
6
+ deterministic. The competition is finished; this is a portfolio reconstruction
7
+ whose target is the winner's `0.3964` R².
8
+
9
+ ---
10
+
11
+ ## 1. Executive summary
12
+
13
+ The solution is a **causal unidirectional GRU** that predicts the next state at
14
+ every step, carrying its hidden state across the sequence so each online
15
+ prediction is O(1). It is trained on full 1000-step sequences with
16
+ back-prop-through-time and a loss masked to the scored steps (current steps
17
+ 100–998 → targets 101–999). A multi-seed / multi-architecture ensemble is
18
+ averaged per feature.
19
+
20
+ Headline result (leaderboard-comparable, 5-fold grouped CV by `seq_ix`):
21
+
22
+ | Model | Dev CV mean R² | Notes |
23
+ | --- | ---: | --- |
24
+ | Persistence (predict current) | −0.37 | sanity baseline |
25
+ | EWMA α=0.10 | 0.217 | best stateful linear baseline |
26
+ | Ridge causal (640 feats) | 0.327 | at the linear ceiling |
27
+ | **Single causal GRU (d256)** | **0.389** | per-fold 0.391 / 0.394 / 0.407 / 0.363 |
28
+ | **GRU ensemble (multi-seed/arch)** | _finalized below_ | target ≥ 0.396 |
29
+
30
+ The single GRU already lands in the competition's top-10 band (0.386–0.392).
31
+ Inference costs ≈ **0.7 ms/prediction** single-threaded, ~1 min for a full
32
+ holdout, far inside the 60-minute budget.
33
+
34
+ ---
35
+
36
+ ## 2. Data analysis (what the data dictated)
37
+
38
+ Measured with `scripts/analyze_data.py` on `data/raw/train.parquet`
39
+ (517 sequences × 1000 steps × 32 features, columns named `"0"`…`"31"`):
40
+
41
+ - **Pre-whitened features.** Global mean ≈ 0, std ≈ 1, per-feature std ∈
42
+ [0.985, 1.011], excess kurtosis ≈ 0, hard-clipped at ±5.199. A
43
+ Gaussianizing/quantile transform was applied → no heavy tails, MSE is a
44
+ well-behaved objective, and no bespoke normalization is needed.
45
+ - **Mean-reverting, not a random walk.** Persistence R² ≈ −0.37 and
46
+ `var(next−current)/var(next) = 1.39` (differencing makes it *worse*), so the
47
+ model predicts the **level**, not a delta. Lag-1 autocorrelation ≈ 0.14.
48
+ - **The linear ceiling is ≈ 0.32.** VAR(p) Ridge saturates: p=1→0.23, p=2→0.28,
49
+ p=4→0.30, p=8→0.31, p=16→0.32. The Ridge causal baseline (0.327) is already
50
+ at this ceiling.
51
+ - **Nonlinearity is the lever (~+0.03).** Histogram gradient boosting on VAR(8)
52
+ features beats Ridge on every feature by +0.015…+0.055, with the largest
53
+ gains on the linearly-hardest features.
54
+
55
+ Conclusion: linear models top out near 0.32; the ~0.07 gap to the winner is
56
+ **nonlinear temporal structure**, which a sequence model is built to capture.
57
+ This is why the primary model is a GRU rather than more feature engineering.
58
+
59
+ ---
60
+
61
+ ## 3. Architecture
62
+
63
+ `src/models/sequence_models.py::CausalGRUForecaster`
64
+
65
+ ```
66
+ state_t (32) ──► Linear(32→d) ─► LayerNorm ─► GRU(d, n_layers, unidirectional)
67
+ │ h_t
68
+ ▼
69
+ LayerNorm ─► Linear(d→d) ─► GELU ─► Dropout ─► Linear(d→32) ─► pred of state_{t+1}
70
+ ```
71
+
72
+ - **Unidirectional & causal** — uses only past/current states, matching the
73
+ online task. A bidirectional model would leak the future and is invalid here.
74
+ - **Stateful O(1) inference** — `forward(x, h0)` and a one-step `step(state, h)`
75
+ share the same recurrence, so the batched training forward and the row-by-row
76
+ online replay are numerically identical (verified to the digit and in
77
+ `tests/test_sequence_model.py`).
78
+ - Default size d_model=256, 2 layers (~0.87 M params); diversity members use
79
+ d_model=384 and a 3-layer variant.
80
+
81
+ ---
82
+
83
+ ## 4. Training strategy
84
+
85
+ `src/training/sequence_trainer.py`
86
+
87
+ - **Full-sequence BPTT.** Each 1000-step sequence is processed end-to-end; the
88
+ loss is a **masked MSE** over the scored steps only (`need_prediction`,
89
+ i.e. current steps 100–998 against the next state). Because targets are
90
+ unit-variance, MSE ≈ (1 − R²), so the objective directly tracks the metric.
91
+ - **Optimizer/schedule.** AdamW (lr 1.5e-3, weight decay 1e-4), OneCycle cosine
92
+ schedule with 10% warmup, gradient clipping at 1.0.
93
+ - **Early stopping** on batched validation R² (identical to the stepwise score),
94
+ patience 14, typically converging by epoch 55–65.
95
+ - **Reproducibility.** Global seeds for Python/NumPy/PyTorch; deterministic
96
+ algorithms enabled; folds are fixed in `config/folds.json`.
97
+ - **Hardware.** Trained on an NVIDIA RTX 3050 Laptop (4 GB) at ~1.8 s/epoch
98
+ (~2 min/run); a CPU-only path (Ryzen 7 5800H, ~24 s/epoch) reproduces the
99
+ same scores within ±0.0003. Submission inference is CPU-only by design.
100
+
101
+ ---
102
+
103
+ ## 5. Validation protocol
104
+
105
+ `src/evaluation/stepwise.py` + `config/folds.json`
106
+
107
+ - **Sequence-grouped** 5-fold split by `seq_ix` (seed 42); fold 4 is an
108
+ untouched final holdout, folds 0–3 are development. No row from a sequence
109
+ ever appears in both train and validation.
110
+ - The **stepwise scorer** replays `PredictionModel.predict` row by row and never
111
+ scores across `seq_ix` boundaries, exactly mirroring the official scorer.
112
+ - The **headline metric is the dev CV mean** across folds 0–3, not a single
113
+ fold: individual 103-sequence folds are high-variance (single-GRU range
114
+ 0.363–0.407), whereas the hidden test (~517 sequences) averages over regimes.
115
+ Fold 4 is reported separately as a conservative point estimate (it is the
116
+ hardest fold; every dev fold scores higher).
117
+
118
+ ---
119
+
120
+ ## 6. Results
121
+
122
+ Cross-validated R² (sequence-grouped folds 0–3), simple-average ensembles:
123
+
124
+ | Ensemble | Members | CV mean R² |
125
+ | --- | ---: | ---: |
126
+ | Single GRU d256 (avg of 3 seeds) | 1 | 0.389 |
127
+ | 3× GRU d256 | 3 | 0.3915 |
128
+ | **3× GRU d256 + 2× GRU d384 (packaged)** | **5** | **0.3922** |
129
+ | + 2× LSTM | 7 | 0.3925 |
130
+
131
+ - Per-feature non-negative stacking matched simple averaging (0.3921 vs 0.3922)
132
+ — expected for a same-family ensemble, so the packaged model averages equally.
133
+ - **Untouched fold-4 holdout** of the 5-member ensemble (fold models that never
134
+ saw fold 4): **0.3498** — the conservative single-fold figure; fold 4 is the
135
+ hardest fold (all dev folds score higher), which is why the CV mean is the
136
+ headline.
137
+ - **Single-core inference:** 4.17 ms/prediction for the 5-member ensemble →
138
+ ≈ **32 minutes** for the full ~517-sequence test on one CPU core (limit: 60).
139
+ - **Deterministic:** identical predictions across repeated runs (max abs diff 0).
140
+
141
+ For reference, the competition's public leaderboard #1 was 0.3920 and the finals
142
+ winner 0.3964; the cross-validated 0.3922 matches the public top.
143
+
144
+ ### Key boosters (most score per unit effort)
145
+ 1. **Causal GRU over linear/tree models** — moved 0.327 → 0.389 CV (+0.062), the
146
+ single biggest jump; it captures the nonlinear temporal structure linear
147
+ models cannot.
148
+ 2. **Predicting the level on whitened inputs** — differencing or persistence is
149
+ actively harmful here (negative R²); letting the GRU learn the mean-reverting
150
+ map directly is the correct framing.
151
+ 3. **Full-sequence stateful modeling** — carrying hidden state from step 0
152
+ matches train and inference exactly and makes online inference O(1).
153
+ 4. **Multi-seed + width-diversity ensembling** — +0.003 over a single GRU
154
+ (0.389 → 0.3922); the largest ensemble gains came from combining d256 and
155
+ d384 seeds rather than adding more of the same.
156
+
157
+ ## 7. What didn't work / didn't help
158
+ - **More training data had little effect** (310 → 352 sequences: +0.003 on the
159
+ holdout). The dev→holdout gap is fold *difficulty*, not data quantity.
160
+ - **Bagging same-config fold models** barely moved the holdout — correlated
161
+ members; diversity (seeds/architectures) is what helps.
162
+ - **Heavy feature engineering / GBDT as the primary model** — capped near the
163
+ linear-plus-small-nonlinear ceiling (~0.34); kept only as an optional
164
+ diversity member, not the backbone.
165
+ - **Differencing / momentum targets** — worse than predicting the level.
166
+ - **Causal TCN (dilated convolutions)** — trained as a decorrelated member but
167
+ scored lower (≈0.37–0.39, fold-dependent) and was far slower at inference (a
168
+ ~250-step receptive field re-run each step). Doubly disqualifying, dropped.
169
+ (A real bug was caught here first: a time-mixing GroupNorm leaked the future;
170
+ fixed with a per-position LayerNorm — see `tests`/the equivalence check.)
171
+ - **GBDT (HistGradientBoosting) as a stacking member** — strongly decorrelated
172
+ (≈0.37) but added only ~+0.0002 to the ensemble while being slow to train OOF;
173
+ not worth the inference complexity. Kept the ensemble pure-GRU.
174
+ - **Adding more ensemble members past 5** — LSTM seeds lifted CV by only +0.0003
175
+ while increasing single-core inference cost; capped at 5 for budget headroom.
176
+ - The earlier repo's "CMDMamba ensemble" was aspirational (no runnable
177
+ artifacts) and based on an unverifiable reference; it was discarded in favor
178
+ of a model justified by the measured data structure.
179
+
180
+ ## 8. Reproduction
181
+
182
+ ```bash
183
+ # environment (CPU submission-faithful)
184
+ py -3.11 -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt
185
+ # optional GPU training env
186
+ py -3.11 -m venv .venv-gpu && .venv-gpu/Scripts/python -m pip install -r requirements-train-gpu.txt
187
+
188
+ python scripts/analyze_data.py # data diagnostics
189
+ python scripts/create_folds.py --output config/folds.json
190
+ python scripts/train_sequence.py --run-name gru --dev-fold 0 --device cuda # one fold
191
+ python scripts/cv_summary.py --runs <run...> # ensemble CV mean
192
+ python scripts/evaluate_ensemble.py --split holdout --gru <ckpt...> # holdout
193
+ python scripts/create_submission.py --output submissions/submission.zip
194
+ python -m pytest tests -q
195
+ ```
requirements.txt ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Wunder Fund RNN Challenge - Dependencies
2
+
3
+ # Core
4
+ numpy==1.26.4
5
+ pandas==2.2.3
6
+ pyarrow==18.1.0
7
+ scikit-learn==1.5.2
8
+ scipy==1.14.1
9
+ joblib==1.4.2
10
+ tqdm==4.66.5
11
+
12
+ # Deep learning (CPU)
13
+ --extra-index-url https://download.pytorch.org/whl/cpu
14
+ torch==2.9.1
15
+ einops==0.8.0
16
+
17
+ # Tree-based models
18
+ lightgbm==4.3.0
19
+ xgboost==2.1.2
20
+
21
+ # ONNX optimization
22
+ onnx==1.17.0
23
+ onnxruntime==1.20.1
24
+
25
+ # Configuration
26
+ pyyaml==6.0.2
27
+
28
+ # Experiment tracking (optional)
29
+ wandb==0.18.7
30
+
31
+ # Hyperparameter optimization (optional)
32
+ optuna==4.1.0
33
+
34
+ # Testing
35
+ pytest==8.3.4
36
+ pytest-cov==6.0.0
solution.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """solution.py — Competition submission for the Wunder Fund RNN Challenge.
2
+
3
+ Root entry point for the scorer. Defines PredictionModel with a predict()
4
+ method that receives DataPoint objects one at a time and predicts the *next*
5
+ state when need_prediction is True.
6
+
7
+ Primary backend: an ensemble of causal GRU forecasters (carrying hidden state
8
+ across steps for O(1)/prediction online inference), optionally blended per
9
+ feature with tabular models. Falls back to joblib tabular models, then a
10
+ momentum baseline (only when ALLOW_FALLBACK=1).
11
+ """
12
+
13
+ import os
14
+
15
+ # Windows: torch and numpy/sklearn each ship an OpenMP runtime; importing torch
16
+ # before the MKL-backed stack avoids a c10.dll init failure. Also keep the
17
+ # submission single-core friendly by default (the scorer grants 1 CPU).
18
+ os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
19
+ _THREADS = os.environ.get("WUNDER_THREADS", "1")
20
+ os.environ.setdefault("OMP_NUM_THREADS", _THREADS)
21
+
22
+ try:
23
+ import torch
24
+ torch.set_num_threads(max(1, int(_THREADS)))
25
+ _HAS_TORCH = True
26
+ except Exception:
27
+ _HAS_TORCH = False
28
+
29
+ import json
30
+ from pathlib import Path
31
+ from typing import Optional
32
+
33
+ import numpy as np
34
+
35
+ _ROOT = Path(__file__).resolve().parent
36
+ import sys
37
+ if str(_ROOT) not in sys.path:
38
+ sys.path.insert(0, str(_ROOT))
39
+
40
+
41
+ class DataPoint:
42
+ """Mirror of competition_package.utils.DataPoint for standalone use."""
43
+
44
+ def __init__(self, seq_ix, step_in_seq, need_prediction, state):
45
+ self.seq_ix = seq_ix
46
+ self.step_in_seq = step_in_seq
47
+ self.need_prediction = need_prediction
48
+ self.state = state
49
+
50
+
51
+ class PredictionModel:
52
+ """Competition PredictionModel. Loads the best available backend."""
53
+
54
+ def __init__(self):
55
+ self.n_features = 32
56
+ self.backend = "fallback"
57
+ self.predictor = None # object exposing predict(data_point)
58
+ self._current_seq = None
59
+ self._prev_state = None
60
+ self._cur_state = None
61
+
62
+ self._try_load_gru()
63
+ if self.predictor is None:
64
+ self._try_load_onnx()
65
+ if self.predictor is None:
66
+ self._try_load_tabular()
67
+ if self.predictor is None:
68
+ self._try_load_pytorch()
69
+ if self.predictor is None and os.environ.get("ALLOW_FALLBACK") != "1":
70
+ raise RuntimeError(
71
+ "No trained model artifacts found. Set ALLOW_FALLBACK=1 only for "
72
+ "local smoke tests; real submissions must package models."
73
+ )
74
+
75
+ # ------------------------------------------------------------------
76
+ # Backends
77
+ # ------------------------------------------------------------------
78
+ def _try_load_gru(self):
79
+ """Primary: GRU ensemble (+ optional tabular members) under models/submission."""
80
+ if not _HAS_TORCH:
81
+ return
82
+ try:
83
+ from src.models.ensemble_predictor import EnsemblePredictionModel
84
+ from src.models.sequence_inference import (
85
+ GRUStatefulPredictionModel,
86
+ load_gru_checkpoint,
87
+ )
88
+
89
+ sub = _ROOT / "models" / "submission"
90
+ gru_paths = sorted((sub / "gru").glob("*.pt")) if (sub / "gru").exists() else []
91
+ if not gru_paths:
92
+ return
93
+
94
+ members, names = [], []
95
+ for p in gru_paths:
96
+ model = load_gru_checkpoint(str(p))
97
+ members.append(GRUStatefulPredictionModel(model))
98
+ names.append(p.stem)
99
+
100
+ # optional tabular members
101
+ tab_dir = sub / "tabular"
102
+ if tab_dir.exists():
103
+ import joblib
104
+ from src.models.baselines import TabularStatefulPredictionModel
105
+ for jb in sorted(tab_dir.glob("*/model.joblib")):
106
+ manifest = jb.parent / "model_manifest.json"
107
+ schema = "compact_v1"
108
+ if manifest.exists():
109
+ try:
110
+ schema = json.loads(manifest.read_text()).get("feature_schema", schema)
111
+ except Exception:
112
+ pass
113
+ members.append(TabularStatefulPredictionModel(joblib.load(str(jb)), feature_schema=schema))
114
+ names.append(jb.parent.name)
115
+
116
+ weights = None
117
+ wpath = sub / "blend_weights.json"
118
+ if wpath.exists():
119
+ try:
120
+ payload = json.loads(wpath.read_text())
121
+ w = np.asarray(payload["per_feature_weights"], dtype=np.float32)
122
+ if w.shape == (self.n_features, len(members)):
123
+ weights = w
124
+ except Exception:
125
+ weights = None
126
+
127
+ self.predictor = EnsemblePredictionModel(members, weights=weights)
128
+ self.backend = "gru_ensemble"
129
+ except Exception:
130
+ self.predictor = None
131
+
132
+ def _try_load_onnx(self):
133
+ """ONNX ensemble (kept for completeness; not the primary path)."""
134
+ return
135
+
136
+ def _try_load_tabular(self):
137
+ """Joblib tabular models discovered under models/ (legacy/baseline path)."""
138
+ try:
139
+ import joblib
140
+ from src.models.baselines import TabularStatefulPredictionModel
141
+ from src.models.ensemble_predictor import EnsemblePredictionModel
142
+
143
+ model_dir = _ROOT / "models"
144
+ joblib_files = sorted(model_dir.glob("phase2_tabular/**/model.joblib"))
145
+ if not joblib_files:
146
+ return
147
+ members = []
148
+ for path in joblib_files:
149
+ manifest = path.parent / "model_manifest.json"
150
+ schema = "compact_v1"
151
+ if manifest.exists():
152
+ try:
153
+ schema = json.loads(manifest.read_text()).get("feature_schema", schema)
154
+ except Exception:
155
+ pass
156
+ members.append(TabularStatefulPredictionModel(joblib.load(str(path)), feature_schema=schema))
157
+ if members:
158
+ self.predictor = EnsemblePredictionModel(members)
159
+ self.backend = "tabular"
160
+ except Exception:
161
+ self.predictor = None
162
+
163
+ def _try_load_pytorch(self):
164
+ """Legacy PyTorch checkpoint path (kept for test compatibility)."""
165
+ return
166
+
167
+ # ------------------------------------------------------------------
168
+ # Predict
169
+ # ------------------------------------------------------------------
170
+ def predict(self, data_point) -> Optional[np.ndarray]:
171
+ if self.predictor is not None:
172
+ return self.predictor.predict(data_point)
173
+ return self._predict_fallback(data_point)
174
+
175
+ def _predict_fallback(self, data_point) -> Optional[np.ndarray]:
176
+ """Momentum baseline; only reachable with ALLOW_FALLBACK=1."""
177
+ if self._current_seq != data_point.seq_ix:
178
+ self._current_seq = data_point.seq_ix
179
+ self._prev_state = None
180
+ self._cur_state = None
181
+ self._prev_state = self._cur_state
182
+ self._cur_state = np.asarray(data_point.state, dtype=np.float32)
183
+ if not data_point.need_prediction:
184
+ return None
185
+ if self._prev_state is None:
186
+ return self._cur_state.astype(np.float32, copy=True)
187
+ return (2.0 * self._cur_state - self._prev_state).astype(np.float32)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ model = PredictionModel()
192
+ print(f"Backend: {model.backend}")
193
+ for step in range(200):
194
+ dp = DataPoint(0, step, step >= 100, np.random.randn(32).astype(np.float32))
195
+ pred = model.predict(dp)
196
+ if pred is not None and step == 100:
197
+ print(f"step {step}: shape={pred.shape} dtype={pred.dtype} finite={np.all(np.isfinite(pred))}")
198
+ print("Local test passed!")
src/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Wunder Fund RNN Challenge - Source Package
src/data/__init__.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data package exports (lazy loading for the Torch-dependent dataset)."""
2
+
3
+ import importlib
4
+
5
+ from .protocol import create_dataset_manifest, create_sequence_folds, resolve_dataset_path, validate_wunder_dataframe
6
+ from .causal_features import (
7
+ build_causal_tabular_features,
8
+ feature_schema_hash,
9
+ feature_schema_payload,
10
+ feature_width,
11
+ )
12
+
13
+ __all__ = [
14
+ 'CausalStepDataset',
15
+ 'create_causal_dataloaders',
16
+ 'create_dataset_manifest',
17
+ 'create_sequence_folds',
18
+ 'resolve_dataset_path',
19
+ 'validate_wunder_dataframe',
20
+ 'build_causal_tabular_features',
21
+ 'feature_schema_hash',
22
+ 'feature_schema_payload',
23
+ 'feature_width',
24
+ ]
25
+
26
+
27
+ def __getattr__(name):
28
+ if name in {'CausalStepDataset', 'create_causal_dataloaders'}:
29
+ return getattr(importlib.import_module(".dataset", __name__), name)
30
+ raise AttributeError(f"module 'src.data' has no attribute {name!r}")
src/data/causal_features.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Causal online feature builders shared by Phase 2 tabular baselines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+
8
+ import numpy as np
9
+
10
+
11
+ COMPACT_V1_WINDOWS = (3, 5, 10, 20, 50, 100)
12
+ EXPANDED_V2_LAGS = (1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610)
13
+ EXPANDED_V2_EWMA_ALPHAS = (0.02, 0.05, 0.10, 0.20, 0.40)
14
+ EXPANDED_V2_WINDOWS = (3, 5, 10, 20, 50, 100, 200, 500)
15
+
16
+
17
+ def _validate_history(history: np.ndarray) -> np.ndarray:
18
+ history = np.asarray(history, dtype=np.float32)
19
+ if history.ndim != 2 or history.shape[1] != 32:
20
+ raise ValueError(f"Expected history shape (t, 32), got {history.shape}")
21
+ if len(history) == 0:
22
+ raise ValueError("history must contain at least one state")
23
+ return history
24
+
25
+
26
+ def _ewma(history: np.ndarray, alpha: float) -> np.ndarray:
27
+ state = history[0].astype(np.float32, copy=True)
28
+ for row in history[1:]:
29
+ state = alpha * row + (1.0 - alpha) * state
30
+ return state.astype(np.float32)
31
+
32
+
33
+ def build_compact_v1_features(history: np.ndarray) -> np.ndarray:
34
+ """Build the original compact raw-32 causal feature vector."""
35
+ history = _validate_history(history)
36
+ current = history[-1]
37
+ prev = history[-2] if len(history) >= 2 else np.zeros_like(current)
38
+ delta1 = current - prev
39
+
40
+ features = [current, delta1]
41
+ for window in COMPACT_V1_WINDOWS:
42
+ chunk = history[-window:]
43
+ mean = chunk.mean(axis=0)
44
+ features.extend([mean, chunk.std(axis=0), current - mean])
45
+ return np.concatenate(features).astype(np.float32)
46
+
47
+
48
+ def build_expanded_v2_features(history: np.ndarray) -> np.ndarray:
49
+ """Build the Phase 2 max-safe expanded causal feature vector."""
50
+ history = _validate_history(history)
51
+ current = history[-1]
52
+ features = [current]
53
+
54
+ for lag in EXPANDED_V2_LAGS:
55
+ lagged = history[-1 - lag] if len(history) > lag else history[0]
56
+ features.extend([lagged, current - lagged])
57
+
58
+ for alpha in EXPANDED_V2_EWMA_ALPHAS:
59
+ smoothed = _ewma(history, alpha)
60
+ features.extend([smoothed, current - smoothed])
61
+
62
+ for window in EXPANDED_V2_WINDOWS:
63
+ chunk = history[-window:]
64
+ mean = chunk.mean(axis=0)
65
+ features.extend(
66
+ [
67
+ mean,
68
+ chunk.std(axis=0),
69
+ chunk.min(axis=0),
70
+ chunk.max(axis=0),
71
+ current - mean,
72
+ ]
73
+ )
74
+ return np.concatenate(features).astype(np.float32)
75
+
76
+
77
+ def build_causal_tabular_features(history: np.ndarray, schema: str = "compact_v1") -> np.ndarray:
78
+ if schema == "compact_v1":
79
+ return build_compact_v1_features(history)
80
+ if schema == "expanded_v2":
81
+ return build_expanded_v2_features(history)
82
+ raise ValueError(f"Unknown feature schema: {schema}")
83
+
84
+
85
+ def feature_width(schema: str) -> int:
86
+ return int(build_causal_tabular_features(np.zeros((1, 32), dtype=np.float32), schema).shape[0])
87
+
88
+
89
+ def infer_feature_schema_from_width(width: int) -> str:
90
+ width = int(width)
91
+ for schema in ("compact_v1", "expanded_v2"):
92
+ if feature_width(schema) == width:
93
+ return schema
94
+ raise ValueError(f"No known causal feature schema has width {width}")
95
+
96
+
97
+ def feature_schema_payload(schema: str) -> dict:
98
+ if schema == "compact_v1":
99
+ return {
100
+ "schema": schema,
101
+ "windows": list(COMPACT_V1_WINDOWS),
102
+ "width": feature_width(schema),
103
+ }
104
+ if schema == "expanded_v2":
105
+ return {
106
+ "schema": schema,
107
+ "lags": list(EXPANDED_V2_LAGS),
108
+ "ewma_alphas": list(EXPANDED_V2_EWMA_ALPHAS),
109
+ "windows": list(EXPANDED_V2_WINDOWS),
110
+ "width": feature_width(schema),
111
+ }
112
+ raise ValueError(f"Unknown feature schema: {schema}")
113
+
114
+
115
+ def feature_schema_hash(schema: str) -> str:
116
+ payload = json.dumps(feature_schema_payload(schema), sort_keys=True).encode("utf-8")
117
+ return hashlib.sha256(payload).hexdigest()
src/data/dataset.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Causal step dataset for the Wunder Fund RNN Challenge.
3
+
4
+ Provides CausalStepDataset (left-padded lookback windows for the official
5
+ stepwise task) and a deterministic train/val loader factory. Targets are
6
+ aligned so that the example at step t predicts state t+1, and only scored steps
7
+ (need_prediction=True, with an available next state) are emitted.
8
+ """
9
+
10
+ import numpy as np
11
+ import torch
12
+ from torch.utils.data import Dataset, DataLoader
13
+ from typing import Optional, List, Tuple
14
+
15
+ from src.data.protocol import get_feature_columns, load_wunder_dataframe
16
+ from src.utils.reproducibility import make_torch_generator, seed_worker
17
+
18
+
19
+ class CausalStepDataset(Dataset):
20
+ """
21
+ Causal online dataset for the official stepwise task.
22
+
23
+ Each sample contains only states up to and including step t and predicts
24
+ state t+1. Samples are emitted only where need_prediction=True and t+1 is
25
+ available in the local parquet file. The hidden competition may request a
26
+ final step prediction whose target is unavailable locally; this dataset
27
+ deliberately excludes that row from training and local validation.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ parquet_path: str,
33
+ seq_ids: Optional[List[int]] = None,
34
+ lookback: int = 128,
35
+ ):
36
+ self.lookback = int(lookback)
37
+ if self.lookback <= 0:
38
+ raise ValueError("lookback must be positive")
39
+
40
+ df = load_wunder_dataframe(parquet_path, seq_ids=seq_ids)
41
+ self.feature_cols = get_feature_columns(df)
42
+ self.n_features = len(self.feature_cols)
43
+ self.samples = []
44
+
45
+ for seq_ix, seq_df in df.groupby("seq_ix", sort=True):
46
+ states = seq_df[self.feature_cols].to_numpy(dtype=np.float32)
47
+ need_prediction = seq_df["need_prediction"].to_numpy(dtype=bool)
48
+ steps = seq_df["step_in_seq"].to_numpy(dtype=np.int64)
49
+ for pos, step in enumerate(steps):
50
+ if not need_prediction[pos]:
51
+ continue
52
+ target_pos = pos + 1
53
+ if target_pos >= len(states):
54
+ continue
55
+ self.samples.append(
56
+ {
57
+ "seq_ix": int(seq_ix),
58
+ "step_in_seq": int(step),
59
+ "states": states,
60
+ "position": int(pos),
61
+ "target": states[target_pos],
62
+ }
63
+ )
64
+
65
+ def __len__(self) -> int:
66
+ return len(self.samples)
67
+
68
+ def __getitem__(self, idx: int) -> dict:
69
+ sample = self.samples[idx]
70
+ states = sample["states"]
71
+ pos = sample["position"]
72
+ start = max(0, pos + 1 - self.lookback)
73
+ history = states[start : pos + 1]
74
+
75
+ window = np.zeros((self.lookback, self.n_features), dtype=np.float32)
76
+ mask = np.zeros((self.lookback,), dtype=np.float32)
77
+ window[-len(history) :] = history
78
+ mask[-len(history) :] = 1.0
79
+
80
+ return {
81
+ "seq_ix": torch.tensor(sample["seq_ix"], dtype=torch.long),
82
+ "step_in_seq": torch.tensor(sample["step_in_seq"], dtype=torch.long),
83
+ "history": torch.tensor(window, dtype=torch.float32),
84
+ "history_mask": torch.tensor(mask, dtype=torch.float32),
85
+ "target": torch.tensor(sample["target"].astype(np.float32, copy=False), dtype=torch.float32),
86
+ }
87
+
88
+
89
+ def create_causal_dataloaders(
90
+ parquet_path: str,
91
+ train_seq_ids: list[int],
92
+ val_seq_ids: list[int],
93
+ *,
94
+ lookback: int = 128,
95
+ batch_size: int = 256,
96
+ num_workers: int = 0,
97
+ seed: int = 42,
98
+ ) -> Tuple[DataLoader, DataLoader]:
99
+ """Create deterministic train/validation loaders for causal step examples."""
100
+ train_dataset = CausalStepDataset(parquet_path, seq_ids=train_seq_ids, lookback=lookback)
101
+ val_dataset = CausalStepDataset(parquet_path, seq_ids=val_seq_ids, lookback=lookback)
102
+
103
+ train_loader = DataLoader(
104
+ train_dataset,
105
+ batch_size=batch_size,
106
+ shuffle=True,
107
+ num_workers=num_workers,
108
+ pin_memory=False,
109
+ worker_init_fn=seed_worker,
110
+ generator=make_torch_generator(seed),
111
+ )
112
+ val_loader = DataLoader(
113
+ val_dataset,
114
+ batch_size=batch_size,
115
+ shuffle=False,
116
+ num_workers=num_workers,
117
+ pin_memory=False,
118
+ )
119
+ return train_loader, val_loader
src/data/protocol.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Official-protocol checks, dataset manifests, and locked sequence folds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import subprocess
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from typing import Iterable, Optional
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+
16
+ REQUIRED_META_COLUMNS = ["seq_ix", "step_in_seq", "need_prediction"]
17
+ EXPECTED_N_FEATURES = 32
18
+ EXPECTED_SEQUENCE_LENGTH = 1000
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class DatasetManifest:
23
+ path: str
24
+ sha256: str
25
+ size_bytes: int
26
+ row_count: int
27
+ sequence_count: int
28
+ sequence_length: int
29
+ feature_columns: list[str]
30
+ need_prediction_counts_by_step: dict[str, int]
31
+ seq_ix_min: int
32
+ seq_ix_max: int
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class FoldManifest:
37
+ seed: int
38
+ n_folds: int
39
+ final_holdout_fold: int
40
+ folds: dict[str, list[int]]
41
+ train_dev_seq_ids: list[int]
42
+ final_holdout_seq_ids: list[int]
43
+ dataset_sha256: Optional[str] = None
44
+ git_commit: Optional[str] = None
45
+
46
+
47
+ def resolve_dataset_path(preferred: str | Path = "data/raw/train.parquet") -> Path:
48
+ """Find the canonical raw train parquet without mutating the workspace."""
49
+ candidates = [
50
+ Path(preferred),
51
+ Path("data/raw/train.parquet"),
52
+ Path("data/train.parquet"),
53
+ Path("competition_package/datasets/train.parquet"),
54
+ Path("wnn_starterpack/competition_package/datasets/train.parquet"),
55
+ Path("datasets/train.parquet"),
56
+ Path("train.parquet"),
57
+ ]
58
+ for candidate in candidates:
59
+ if candidate.exists():
60
+ return candidate
61
+ raise FileNotFoundError(
62
+ "No train parquet found. Expected data/raw/train.parquet, "
63
+ "data/train.parquet, or competition_package/datasets/train.parquet."
64
+ )
65
+
66
+
67
+ def sha256_file(path: str | Path, chunk_size: int = 1024 * 1024) -> str:
68
+ digest = hashlib.sha256()
69
+ with Path(path).open("rb") as f:
70
+ for chunk in iter(lambda: f.read(chunk_size), b""):
71
+ digest.update(chunk)
72
+ return digest.hexdigest()
73
+
74
+
75
+ def get_feature_columns(df: pd.DataFrame) -> list[str]:
76
+ """Return feature columns in scorer/parquet order, never lexicographic sort."""
77
+ return list(df.columns[len(REQUIRED_META_COLUMNS) :])
78
+
79
+
80
+ def validate_wunder_dataframe(
81
+ df: pd.DataFrame,
82
+ *,
83
+ expected_n_features: int = EXPECTED_N_FEATURES,
84
+ expected_sequence_length: int = EXPECTED_SEQUENCE_LENGTH,
85
+ ) -> list[str]:
86
+ """Validate the official data contract and return feature columns."""
87
+ if list(df.columns[:3]) != REQUIRED_META_COLUMNS:
88
+ raise ValueError(
89
+ "First columns must be seq_ix, step_in_seq, need_prediction; "
90
+ f"got {list(df.columns[:3])}"
91
+ )
92
+
93
+ feature_cols = get_feature_columns(df)
94
+ if len(feature_cols) != expected_n_features:
95
+ raise ValueError(
96
+ f"Expected {expected_n_features} feature columns, got {len(feature_cols)}"
97
+ )
98
+ if df[REQUIRED_META_COLUMNS].isna().any().any():
99
+ raise ValueError("Metadata columns contain missing values")
100
+
101
+ lengths = df.groupby("seq_ix", sort=True).size()
102
+ bad_lengths = lengths[lengths != expected_sequence_length]
103
+ if not bad_lengths.empty:
104
+ raise ValueError(
105
+ "Every sequence must have exactly "
106
+ f"{expected_sequence_length} rows; bad seq_ix values: "
107
+ f"{bad_lengths.index.tolist()[:10]}"
108
+ )
109
+
110
+ expected_steps = np.arange(expected_sequence_length)
111
+ for seq_ix, seq_df in df.groupby("seq_ix", sort=False):
112
+ steps = seq_df["step_in_seq"].to_numpy()
113
+ if not np.array_equal(steps, expected_steps):
114
+ raise ValueError(
115
+ f"Sequence {seq_ix} must be ordered with steps 0.."
116
+ f"{expected_sequence_length - 1}"
117
+ )
118
+
119
+ if df[feature_cols].isna().any().any():
120
+ raise ValueError("Feature columns contain missing values")
121
+ return feature_cols
122
+
123
+
124
+ def load_wunder_dataframe(path: str | Path, seq_ids: Optional[Iterable[int]] = None) -> pd.DataFrame:
125
+ """Read and validate the Wunder parquet file."""
126
+ df = pd.read_parquet(path)
127
+ if seq_ids is not None:
128
+ seq_set = set(int(seq_id) for seq_id in seq_ids)
129
+ df = df[df["seq_ix"].isin(seq_set)].copy()
130
+ df = df.sort_values(["seq_ix", "step_in_seq"]).reset_index(drop=True)
131
+ validate_wunder_dataframe(df)
132
+ return df
133
+
134
+
135
+ def create_dataset_manifest(path: str | Path) -> DatasetManifest:
136
+ """Build a reproducibility manifest for a validated parquet file."""
137
+ path = Path(path)
138
+ df = load_wunder_dataframe(path)
139
+ feature_cols = get_feature_columns(df)
140
+ need_counts = (
141
+ df.groupby("step_in_seq")["need_prediction"]
142
+ .sum()
143
+ .astype(int)
144
+ .to_dict()
145
+ )
146
+ seq_ids = sorted(int(x) for x in df["seq_ix"].unique())
147
+ return DatasetManifest(
148
+ path=str(path),
149
+ sha256=sha256_file(path),
150
+ size_bytes=path.stat().st_size,
151
+ row_count=int(len(df)),
152
+ sequence_count=len(seq_ids),
153
+ sequence_length=int(df.groupby("seq_ix").size().iloc[0]),
154
+ feature_columns=feature_cols,
155
+ need_prediction_counts_by_step={str(k): int(v) for k, v in need_counts.items()},
156
+ seq_ix_min=min(seq_ids),
157
+ seq_ix_max=max(seq_ids),
158
+ )
159
+
160
+
161
+ def write_json(path: str | Path, payload: object) -> None:
162
+ """Write dataclasses/dicts as stable JSON."""
163
+ path = Path(path)
164
+ path.parent.mkdir(parents=True, exist_ok=True)
165
+ if hasattr(payload, "__dataclass_fields__"):
166
+ payload = asdict(payload)
167
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
168
+
169
+
170
+ def read_json(path: str | Path) -> dict:
171
+ return json.loads(Path(path).read_text(encoding="utf-8"))
172
+
173
+
174
+ def current_git_commit() -> Optional[str]:
175
+ try:
176
+ return subprocess.check_output(
177
+ ["git", "rev-parse", "HEAD"],
178
+ text=True,
179
+ stderr=subprocess.DEVNULL,
180
+ ).strip()
181
+ except Exception:
182
+ return None
183
+
184
+
185
+ def create_sequence_folds(
186
+ seq_ids: Iterable[int],
187
+ *,
188
+ n_folds: int = 5,
189
+ seed: int = 42,
190
+ final_holdout_fold: int = 4,
191
+ dataset_sha256: Optional[str] = None,
192
+ ) -> FoldManifest:
193
+ """Create deterministic folds by seq_ix with one untouched final holdout fold."""
194
+ seq_ids = np.array(sorted(int(x) for x in seq_ids), dtype=int)
195
+ if len(seq_ids) < n_folds:
196
+ raise ValueError("Need at least as many sequences as folds")
197
+ if final_holdout_fold < 0 or final_holdout_fold >= n_folds:
198
+ raise ValueError("final_holdout_fold must be in [0, n_folds)")
199
+
200
+ rng = np.random.RandomState(seed)
201
+ shuffled = seq_ids.copy()
202
+ rng.shuffle(shuffled)
203
+ split_ids = np.array_split(shuffled, n_folds)
204
+ folds = {
205
+ str(i): sorted(int(x) for x in split_ids[i].tolist())
206
+ for i in range(n_folds)
207
+ }
208
+ final_holdout = folds[str(final_holdout_fold)]
209
+ train_dev = sorted(
210
+ int(seq_id)
211
+ for fold_id, fold_seq_ids in folds.items()
212
+ if int(fold_id) != final_holdout_fold
213
+ for seq_id in fold_seq_ids
214
+ )
215
+ return FoldManifest(
216
+ seed=seed,
217
+ n_folds=n_folds,
218
+ final_holdout_fold=final_holdout_fold,
219
+ folds=folds,
220
+ train_dev_seq_ids=train_dev,
221
+ final_holdout_seq_ids=final_holdout,
222
+ dataset_sha256=dataset_sha256,
223
+ git_commit=current_git_commit(),
224
+ )
225
+
226
+
227
+ def load_sequence_folds(path: str | Path) -> FoldManifest:
228
+ payload = read_json(path)
229
+ return FoldManifest(**payload)
src/evaluation/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .stepwise import StepwiseScoreResult, StepwiseScorer
2
+
3
+ __all__ = ["StepwiseScoreResult", "StepwiseScorer"]
src/evaluation/stepwise.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Official-style row-by-row evaluation without sequence-boundary leakage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from competition_package.utils import DataPoint
12
+ from src.data.protocol import get_feature_columns, validate_wunder_dataframe
13
+ from src.utils.metrics import compute_r2_per_feature, compute_r2_score
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class StepwiseScoreResult:
18
+ mean_r2: float
19
+ r2_per_feature: dict[str, float]
20
+ n_predictions_scored: int
21
+ n_predictions_requested: int
22
+ n_predictions_dropped_final_step: int
23
+ feature_columns: list[str]
24
+
25
+
26
+ class StepwiseScorer:
27
+ """Replay held-out sequences through the competition `predict` API."""
28
+
29
+ def __init__(self, dataframe: pd.DataFrame):
30
+ self.dataset = dataframe.sort_values(["seq_ix", "step_in_seq"]).reset_index(drop=True)
31
+ self.feature_columns = validate_wunder_dataframe(self.dataset)
32
+ self.dim = len(self.feature_columns)
33
+
34
+ @classmethod
35
+ def from_parquet(cls, path: str, seq_ids: Optional[list[int]] = None) -> "StepwiseScorer":
36
+ df = pd.read_parquet(path)
37
+ if seq_ids is not None:
38
+ df = df[df["seq_ix"].isin(set(seq_ids))].copy()
39
+ return cls(df)
40
+
41
+ def score(self, model) -> StepwiseScoreResult:
42
+ predictions = []
43
+ targets = []
44
+ pending_prediction = None
45
+ pending_seq_ix = None
46
+ requested = 0
47
+ dropped_final = 0
48
+
49
+ for _, seq_df in self.dataset.groupby("seq_ix", sort=True):
50
+ pending_prediction = None
51
+ pending_seq_ix = None
52
+
53
+ seq_ix_values = seq_df["seq_ix"].to_numpy()
54
+ step_values = seq_df["step_in_seq"].to_numpy()
55
+ need_values = seq_df["need_prediction"].to_numpy()
56
+ state_values = seq_df[self.feature_columns].to_numpy(dtype=np.float32)
57
+
58
+ for pos in range(len(seq_df)):
59
+ seq_ix = int(seq_ix_values[pos])
60
+ step_in_seq = int(step_values[pos])
61
+ need_prediction = bool(need_values[pos])
62
+ state = state_values[pos]
63
+
64
+ if pending_prediction is not None:
65
+ if pending_seq_ix != seq_ix:
66
+ raise AssertionError("Internal scorer leaked across sequences")
67
+ predictions.append(pending_prediction)
68
+ targets.append(state)
69
+
70
+ data_point = DataPoint(seq_ix, step_in_seq, need_prediction, state)
71
+ next_prediction = model.predict(data_point)
72
+ self._check_prediction(data_point, next_prediction)
73
+ if need_prediction:
74
+ requested += 1
75
+ pending_prediction = next_prediction
76
+ pending_seq_ix = seq_ix
77
+
78
+ if pending_prediction is not None:
79
+ dropped_final += 1
80
+
81
+ if not predictions:
82
+ raise ValueError("No predictions were scored")
83
+
84
+ y_pred = np.asarray(predictions, dtype=np.float64)
85
+ y_true = np.asarray(targets, dtype=np.float64)
86
+ return StepwiseScoreResult(
87
+ mean_r2=compute_r2_score(y_true, y_pred),
88
+ r2_per_feature=compute_r2_per_feature(y_true, y_pred, self.feature_columns),
89
+ n_predictions_scored=len(predictions),
90
+ n_predictions_requested=requested,
91
+ n_predictions_dropped_final_step=dropped_final,
92
+ feature_columns=self.feature_columns,
93
+ )
94
+
95
+ def _check_prediction(self, data_point: DataPoint, prediction: Optional[np.ndarray]) -> None:
96
+ if not data_point.need_prediction:
97
+ if prediction is not None:
98
+ raise ValueError(f"Prediction is not needed for {data_point}")
99
+ return
100
+ if prediction is None:
101
+ raise ValueError(f"Prediction is required for {data_point}")
102
+ if not isinstance(prediction, np.ndarray):
103
+ raise ValueError(f"Prediction must be np.ndarray, got {type(prediction)}")
104
+ if prediction.shape != (self.dim,):
105
+ raise ValueError(f"Prediction has wrong shape: {prediction.shape} != {(self.dim,)}")
106
+ if not np.isfinite(prediction).all():
107
+ raise ValueError("Prediction contains NaN or Inf")
src/models/__init__.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model package exports (lazy to keep torch optional for tabular-only use)."""
2
+
3
+ import importlib
4
+
5
+ __all__ = [
6
+ 'PersistencePredictionModel',
7
+ 'MomentumPredictionModel',
8
+ 'EWMAPredictionModel',
9
+ 'TabularStatefulPredictionModel',
10
+ 'build_causal_tabular_features',
11
+ 'CausalGRUForecaster',
12
+ 'CausalTCN',
13
+ 'EnsemblePredictionModel',
14
+ ]
15
+
16
+
17
+ def __getattr__(name):
18
+ if name in {
19
+ 'PersistencePredictionModel',
20
+ 'MomentumPredictionModel',
21
+ 'EWMAPredictionModel',
22
+ 'TabularStatefulPredictionModel',
23
+ 'build_causal_tabular_features',
24
+ }:
25
+ return getattr(importlib.import_module(".baselines", __name__), name)
26
+ if name == 'CausalGRUForecaster':
27
+ return getattr(importlib.import_module(".sequence_models", __name__), name)
28
+ if name == 'CausalTCN':
29
+ return getattr(importlib.import_module(".tcn", __name__), name)
30
+ if name == 'EnsemblePredictionModel':
31
+ return getattr(importlib.import_module(".ensemble_predictor", __name__), name)
32
+ raise AttributeError(f"module 'src.models' has no attribute {name!r}")
src/models/baselines.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Causal baseline predictors for the Wunder stepwise API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import numpy as np
8
+
9
+ from src.data.causal_features import build_causal_tabular_features
10
+
11
+
12
+ class PersistencePredictionModel:
13
+ """Predict the current state as the next state."""
14
+
15
+ def __init__(self, n_features: int = 32):
16
+ self.n_features = n_features
17
+ self.current_seq: Optional[int] = None
18
+ self.last_state: Optional[np.ndarray] = None
19
+
20
+ def predict(self, data_point):
21
+ if self.current_seq != data_point.seq_ix:
22
+ self.current_seq = data_point.seq_ix
23
+ self.last_state = None
24
+ self.last_state = data_point.state.astype(np.float32, copy=True)
25
+ if not data_point.need_prediction:
26
+ return None
27
+ return self.last_state.astype(np.float32, copy=True)
28
+
29
+
30
+ class MomentumPredictionModel:
31
+ """Predict next state with a first-difference momentum term."""
32
+
33
+ def __init__(self, alpha: float = 1.0, n_features: int = 32):
34
+ self.alpha = float(alpha)
35
+ self.n_features = n_features
36
+ self.current_seq: Optional[int] = None
37
+ self.previous_state: Optional[np.ndarray] = None
38
+ self.current_state: Optional[np.ndarray] = None
39
+
40
+ def predict(self, data_point):
41
+ if self.current_seq != data_point.seq_ix:
42
+ self.current_seq = data_point.seq_ix
43
+ self.previous_state = None
44
+ self.current_state = None
45
+
46
+ self.previous_state = self.current_state
47
+ self.current_state = data_point.state.astype(np.float32, copy=True)
48
+ if not data_point.need_prediction:
49
+ return None
50
+ if self.previous_state is None:
51
+ return self.current_state.astype(np.float32, copy=True)
52
+ pred = self.current_state + self.alpha * (self.current_state - self.previous_state)
53
+ return pred.astype(np.float32)
54
+
55
+
56
+ class EWMAPredictionModel:
57
+ """Predict with an exponentially weighted moving average state."""
58
+
59
+ def __init__(self, alpha: float = 0.2, n_features: int = 32):
60
+ if not 0.0 < alpha <= 1.0:
61
+ raise ValueError("alpha must be in (0, 1]")
62
+ self.alpha = float(alpha)
63
+ self.n_features = n_features
64
+ self.current_seq: Optional[int] = None
65
+ self.ewma_state: Optional[np.ndarray] = None
66
+
67
+ def predict(self, data_point):
68
+ if self.current_seq != data_point.seq_ix:
69
+ self.current_seq = data_point.seq_ix
70
+ self.ewma_state = None
71
+ state = data_point.state.astype(np.float32, copy=False)
72
+ if self.ewma_state is None:
73
+ self.ewma_state = state.copy()
74
+ else:
75
+ self.ewma_state = self.alpha * state + (1.0 - self.alpha) * self.ewma_state
76
+ if not data_point.need_prediction:
77
+ return None
78
+ return self.ewma_state.astype(np.float32, copy=True)
79
+
80
+
81
+ class TabularStatefulPredictionModel:
82
+ """Stateful wrapper for sklearn-style multi-output tabular regressors."""
83
+
84
+ def __init__(self, estimator, n_features: int = 32, feature_schema: str = "compact_v1"):
85
+ self.estimator = estimator
86
+ self.n_features = n_features
87
+ self.feature_schema = feature_schema
88
+ self.current_seq: Optional[int] = None
89
+ self.history: list[np.ndarray] = []
90
+
91
+ def predict(self, data_point):
92
+ if self.current_seq != data_point.seq_ix:
93
+ self.current_seq = data_point.seq_ix
94
+ self.history = []
95
+ self.history.append(data_point.state.astype(np.float32, copy=True))
96
+ if not data_point.need_prediction:
97
+ return None
98
+ features = build_causal_tabular_features(
99
+ np.asarray(self.history, dtype=np.float32),
100
+ schema=self.feature_schema,
101
+ )
102
+ pred = self.estimator.predict(features.reshape(1, -1))[0]
103
+ return np.asarray(pred, dtype=np.float32)
src/models/ensemble_predictor.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Uniform ensemble PredictionModel for the Wunder stepwise task.
2
+
3
+ Both GRUStatefulPredictionModel and TabularStatefulPredictionModel expose the
4
+ same contract: ``predict(data_point)`` advances per-sequence internal state and
5
+ returns None during warm-up or an (n_features,) array when a prediction is
6
+ needed. An ensemble is therefore just a list of member predictors plus an
7
+ optional per-feature weight matrix. The same class powers local holdout
8
+ evaluation and the packaged solution.py.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ import numpy as np
16
+
17
+
18
+ class EnsemblePredictionModel:
19
+ def __init__(self, members: list, weights: Optional[np.ndarray] = None, n_features: int = 32):
20
+ """Args:
21
+ members: objects exposing predict(data_point) -> Optional[np.ndarray].
22
+ weights: optional (n_features, n_members) blend weights. If None, the
23
+ members are averaged equally.
24
+ """
25
+ if not members:
26
+ raise ValueError("EnsemblePredictionModel needs at least one member")
27
+ self.members = members
28
+ self.n_features = n_features
29
+ self.weights = None
30
+ if weights is not None:
31
+ weights = np.asarray(weights, dtype=np.float32)
32
+ if weights.shape != (n_features, len(members)):
33
+ raise ValueError(
34
+ f"weights shape {weights.shape} != {(n_features, len(members))}"
35
+ )
36
+ self.weights = weights
37
+
38
+ def predict(self, data_point) -> Optional[np.ndarray]:
39
+ # Always advance every member's state, even during warm-up.
40
+ member_preds = [m.predict(data_point) for m in self.members]
41
+ if not data_point.need_prediction:
42
+ return None
43
+ stacked = np.stack([np.asarray(p, dtype=np.float32) for p in member_preds], axis=0) # (M, F)
44
+ if self.weights is not None:
45
+ # out[f] = sum_m stacked[m, f] * weights[f, m]
46
+ out = np.einsum("mf,fm->f", stacked, self.weights)
47
+ else:
48
+ out = stacked.mean(axis=0)
49
+ return out.astype(np.float32)
src/models/sequence_inference.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Online stateful inference wrapper for the causal GRU.
2
+
3
+ Carries the GRU hidden state across steps so each prediction is O(1) work,
4
+ which is what makes the model fit the 1-core / 60-minute submission budget.
5
+ Used by both local stepwise evaluation and the packaged solution.py.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ import numpy as np
14
+ import torch
15
+
16
+ from src.models.sequence_models import CausalGRUForecaster
17
+
18
+
19
+ class GRUStatefulPredictionModel:
20
+ """Wrap a CausalGRUForecaster as a competition PredictionModel."""
21
+
22
+ def __init__(self, model: CausalGRUForecaster, n_features: int = 32):
23
+ self.model = model.eval()
24
+ self.n_features = n_features
25
+ self.current_seq: Optional[int] = None
26
+ self.h: Optional[torch.Tensor] = None
27
+
28
+ def reset(self):
29
+ self.current_seq = None
30
+ self.h = None
31
+
32
+ @torch.no_grad()
33
+ def predict(self, data_point):
34
+ if self.current_seq != data_point.seq_ix:
35
+ self.current_seq = data_point.seq_ix
36
+ self.h = None
37
+ state = torch.from_numpy(np.asarray(data_point.state, dtype=np.float32))
38
+ pred, self.h = self.model.step(state, self.h)
39
+ if not data_point.need_prediction:
40
+ return None
41
+ return pred.detach().numpy().astype(np.float32)
42
+
43
+
44
+ def build_model_from_config(params: dict, model_type: str = "CausalGRUForecaster"):
45
+ if model_type == "CausalTCN":
46
+ from src.models.tcn import CausalTCN
47
+ return CausalTCN(
48
+ n_features=params.get("n_features", 32),
49
+ d_model=params.get("d_model", 192),
50
+ n_layers=params.get("n_layers", 6),
51
+ kernel_size=params.get("kernel_size", 3),
52
+ dropout=params.get("dropout", 0.1),
53
+ head_hidden=params.get("head_hidden"),
54
+ )
55
+ return CausalGRUForecaster(
56
+ n_features=params.get("n_features", 32),
57
+ d_model=params.get("d_model", 256),
58
+ n_layers=params.get("n_layers", 2),
59
+ dropout=params.get("dropout", 0.1),
60
+ head_hidden=params.get("head_hidden"),
61
+ rnn_type=params.get("rnn_type", "gru"),
62
+ )
63
+
64
+
65
+ def load_gru_checkpoint(path: str | Path, map_location: str = "cpu"):
66
+ """Reconstruct a forecaster (GRU/LSTM/TCN) from a saved checkpoint."""
67
+ ckpt = torch.load(str(path), map_location=map_location, weights_only=False)
68
+ model_cfg = ckpt.get("config", {}).get("model", {})
69
+ params = model_cfg.get("params", {})
70
+ model = build_model_from_config(params, model_cfg.get("type", "CausalGRUForecaster"))
71
+ model.load_state_dict(ckpt["model_state_dict"])
72
+ model.eval()
73
+ return model
src/models/sequence_models.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Causal sequence models for the Wunder stepwise next-state task.
2
+
3
+ Design constraints derived from the data analysis (see memory / report):
4
+ - Features are pre-whitened (~N(0,1), no heavy tails) so no internal
5
+ normalization is required; the model predicts the next *level* directly.
6
+ - Inference is online, one row at a time, on a single CPU core within a
7
+ 60-minute budget. A unidirectional GRU carries hidden state across steps,
8
+ giving O(1) work per prediction.
9
+ - Training uses full 1000-step sequences with back-prop-through-time and a
10
+ loss masked to the scored steps (100..998). Because a GRU is a pure
11
+ recurrence, the full-sequence forward and the stepwise stateful forward
12
+ produce identical outputs — so what we train is exactly what we score.
13
+
14
+ `forward(x, h0)` returns per-step predictions of the *next* state:
15
+ preds[:, t] predicts x[:, t + 1].
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Optional, Tuple
21
+
22
+ import torch
23
+ import torch.nn as nn
24
+
25
+
26
+ class CausalGRUForecaster(nn.Module):
27
+ """Unidirectional GRU that predicts the next state at every step.
28
+
29
+ Args:
30
+ n_features: input/output width (32).
31
+ d_model: GRU hidden size and embedding width.
32
+ n_layers: stacked GRU layers.
33
+ dropout: dropout between GRU layers and inside the head.
34
+ head_hidden: width of the MLP head hidden layer (defaults to d_model).
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ n_features: int = 32,
40
+ d_model: int = 256,
41
+ n_layers: int = 2,
42
+ dropout: float = 0.1,
43
+ head_hidden: Optional[int] = None,
44
+ rnn_type: str = "gru",
45
+ ):
46
+ super().__init__()
47
+ self.n_features = int(n_features)
48
+ self.d_model = int(d_model)
49
+ self.n_layers = int(n_layers)
50
+ self.rnn_type = str(rnn_type).lower()
51
+ head_hidden = int(head_hidden) if head_hidden else self.d_model
52
+
53
+ # Per-step input embedding (applied identically in batch and stepwise modes).
54
+ self.input_proj = nn.Linear(n_features, d_model)
55
+ self.input_norm = nn.LayerNorm(d_model)
56
+
57
+ rnn_cls = nn.LSTM if self.rnn_type == "lstm" else nn.GRU
58
+ self.gru = rnn_cls(
59
+ input_size=d_model,
60
+ hidden_size=d_model,
61
+ num_layers=n_layers,
62
+ batch_first=True,
63
+ dropout=dropout if n_layers > 1 else 0.0,
64
+ )
65
+
66
+ self.head = nn.Sequential(
67
+ nn.LayerNorm(d_model),
68
+ nn.Linear(d_model, head_hidden),
69
+ nn.GELU(),
70
+ nn.Dropout(dropout),
71
+ nn.Linear(head_hidden, n_features),
72
+ )
73
+
74
+ def forward(
75
+ self,
76
+ x: torch.Tensor,
77
+ h0: Optional[torch.Tensor] = None,
78
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
79
+ """Args:
80
+ x: (batch, seq_len, n_features)
81
+ h0: optional initial hidden state (n_layers, batch, d_model)
82
+ Returns:
83
+ preds: (batch, seq_len, n_features) where preds[:, t] predicts x[:, t+1]
84
+ h_n: final hidden state (n_layers, batch, d_model)
85
+ """
86
+ z = self.input_norm(self.input_proj(x))
87
+ out, h_n = self.gru(z, h0)
88
+ preds = self.head(out)
89
+ return preds, h_n
90
+
91
+ @torch.no_grad()
92
+ def step(
93
+ self,
94
+ state: torch.Tensor,
95
+ h: Optional[torch.Tensor] = None,
96
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
97
+ """Single online step.
98
+
99
+ Args:
100
+ state: (n_features,) or (1, n_features) current state.
101
+ h: previous hidden state (n_layers, 1, d_model) or None.
102
+ Returns:
103
+ pred: (n_features,) prediction of the next state.
104
+ h_new: updated hidden state.
105
+ """
106
+ if state.dim() == 1:
107
+ state = state.view(1, 1, -1)
108
+ elif state.dim() == 2:
109
+ state = state.unsqueeze(1)
110
+ preds, h_new = self.forward(state, h)
111
+ return preds[0, -1], h_new
112
+
113
+
114
+ def build_gru(config: dict) -> CausalGRUForecaster:
115
+ """Construct a CausalGRUForecaster from a plain dict config."""
116
+ return CausalGRUForecaster(
117
+ n_features=config.get("n_features", 32),
118
+ d_model=config.get("d_model", 256),
119
+ n_layers=config.get("n_layers", 2),
120
+ dropout=config.get("dropout", 0.1),
121
+ head_hidden=config.get("head_hidden"),
122
+ rnn_type=config.get("rnn_type", "gru"),
123
+ )
124
+
125
+
126
+ def count_parameters(model: nn.Module) -> int:
127
+ return sum(p.numel() for p in model.parameters() if p.requires_grad)
src/models/tcn.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Causal Temporal Convolutional Network for next-state prediction.
2
+
3
+ A different inductive bias from the RNNs (dilated causal convolutions instead of
4
+ a recurrence), which decorrelates its errors from the GRU/LSTM members and so
5
+ adds more in an ensemble than another RNN seed.
6
+
7
+ Exposes the same interface as CausalGRUForecaster so it is a drop-in for the
8
+ trainer, the stateful inference wrapper, and the ensembler:
9
+ forward(x, h0=None) -> (preds, None) where preds[:, t] predicts x[:, t+1]
10
+ step(state, h) -> (pred, buffer) buffer holds the last receptive-field states
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional, Tuple
16
+
17
+ import torch
18
+ import torch.nn as nn
19
+
20
+
21
+ class _ChannelLayerNorm(nn.Module):
22
+ """LayerNorm over the channel dim of a (B, C, T) tensor, independent per
23
+ timestep — causal, unlike GroupNorm/BatchNorm which mix statistics across T."""
24
+
25
+ def __init__(self, d_model: int):
26
+ super().__init__()
27
+ self.norm = nn.LayerNorm(d_model)
28
+
29
+ def forward(self, x): # (B, C, T)
30
+ return self.norm(x.transpose(1, 2)).transpose(1, 2)
31
+
32
+
33
+ class _CausalConvBlock(nn.Module):
34
+ def __init__(self, d_model: int, kernel_size: int, dilation: int, dropout: float):
35
+ super().__init__()
36
+ self.pad = (kernel_size - 1) * dilation
37
+ self.conv1 = nn.Conv1d(d_model, d_model, kernel_size, dilation=dilation, padding=self.pad)
38
+ self.conv2 = nn.Conv1d(d_model, d_model, kernel_size, dilation=dilation, padding=self.pad)
39
+ self.norm1 = _ChannelLayerNorm(d_model)
40
+ self.norm2 = _ChannelLayerNorm(d_model)
41
+ self.act = nn.GELU()
42
+ self.drop = nn.Dropout(dropout)
43
+
44
+ def forward(self, x): # x: (B, d, T)
45
+ T = x.shape[-1]
46
+ h = self.conv1(x)[..., :T] # trim right (future) padding -> causal
47
+ h = self.drop(self.act(self.norm1(h)))
48
+ h = self.conv2(h)[..., :T]
49
+ h = self.drop(self.act(self.norm2(h)))
50
+ return x + h
51
+
52
+
53
+ class CausalTCN(nn.Module):
54
+ def __init__(
55
+ self,
56
+ n_features: int = 32,
57
+ d_model: int = 192,
58
+ n_layers: int = 6,
59
+ kernel_size: int = 3,
60
+ dropout: float = 0.1,
61
+ head_hidden: Optional[int] = None,
62
+ ):
63
+ super().__init__()
64
+ self.n_features = int(n_features)
65
+ self.d_model = int(d_model)
66
+ self.n_layers = int(n_layers)
67
+ self.kernel_size = int(kernel_size)
68
+ head_hidden = int(head_hidden) if head_hidden else d_model
69
+
70
+ self.input_proj = nn.Linear(n_features, d_model)
71
+ self.input_norm = nn.LayerNorm(d_model)
72
+ self.blocks = nn.ModuleList([
73
+ _CausalConvBlock(d_model, kernel_size, dilation=2 ** i, dropout=dropout)
74
+ for i in range(n_layers)
75
+ ])
76
+ self.head = nn.Sequential(
77
+ nn.LayerNorm(d_model),
78
+ nn.Linear(d_model, head_hidden),
79
+ nn.GELU(),
80
+ nn.Dropout(dropout),
81
+ nn.Linear(head_hidden, n_features),
82
+ )
83
+ # receptive field for buffered inference
84
+ self.receptive_field = 1 + 2 * sum((kernel_size - 1) * (2 ** i) for i in range(n_layers))
85
+
86
+ def forward(self, x: torch.Tensor, h0=None) -> Tuple[torch.Tensor, None]:
87
+ z = self.input_norm(self.input_proj(x)) # (B, T, d)
88
+ z = z.transpose(1, 2) # (B, d, T)
89
+ for block in self.blocks:
90
+ z = block(z)
91
+ z = z.transpose(1, 2) # (B, T, d)
92
+ return self.head(z), None
93
+
94
+ @torch.no_grad()
95
+ def step(self, state: torch.Tensor, h: Optional[torch.Tensor] = None):
96
+ """h is the running buffer of recent states (1, L, F)."""
97
+ if state.dim() == 1:
98
+ state = state.view(1, 1, -1)
99
+ elif state.dim() == 2:
100
+ state = state.unsqueeze(1)
101
+ buf = state if h is None else torch.cat([h, state], dim=1)
102
+ buf = buf[:, -self.receptive_field:]
103
+ preds, _ = self.forward(buf)
104
+ return preds[0, -1], buf
105
+
106
+
107
+ def build_tcn(config: dict) -> CausalTCN:
108
+ return CausalTCN(
109
+ n_features=config.get("n_features", 32),
110
+ d_model=config.get("d_model", 192),
111
+ n_layers=config.get("n_layers", 6),
112
+ kernel_size=config.get("kernel_size", 3),
113
+ dropout=config.get("dropout", 0.1),
114
+ head_hidden=config.get("head_hidden"),
115
+ )
src/training/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training package exports (lazy loading for the Torch-dependent trainer)."""
2
+
3
+ import importlib
4
+
5
+ __all__ = [
6
+ 'build_tabular_supervised_matrix',
7
+ 'create_tabular_estimator',
8
+ 'TrainConfig',
9
+ 'train_sequence_model',
10
+ ]
11
+
12
+
13
+ def __getattr__(name):
14
+ if name in {'build_tabular_supervised_matrix', 'create_tabular_estimator'}:
15
+ return getattr(importlib.import_module(".baseline_training", __name__), name)
16
+ if name in {'TrainConfig', 'train_sequence_model'}:
17
+ return getattr(importlib.import_module(".sequence_trainer", __name__), name)
18
+ raise AttributeError(f"module 'src.training' has no attribute {name!r}")
src/training/baseline_training.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training utilities for Phase 2 causal tabular baselines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ from src.data.protocol import get_feature_columns, load_wunder_dataframe
9
+ from src.models.baselines import build_causal_tabular_features
10
+
11
+
12
+ def build_tabular_supervised_matrix(
13
+ parquet_path: str,
14
+ seq_ids: list[int],
15
+ *,
16
+ feature_schema: str = "compact_v1",
17
+ ) -> tuple[np.ndarray, np.ndarray]:
18
+ """Build causal tabular features and next-state targets for selected sequences."""
19
+ df = load_wunder_dataframe(parquet_path, seq_ids=seq_ids)
20
+ feature_cols = get_feature_columns(df)
21
+ rows = []
22
+ targets = []
23
+ for _, seq_df in df.groupby("seq_ix", sort=True):
24
+ states = seq_df[feature_cols].to_numpy(dtype=np.float32)
25
+ need_prediction = seq_df["need_prediction"].to_numpy(dtype=bool)
26
+ for pos in range(len(states) - 1):
27
+ if not need_prediction[pos]:
28
+ continue
29
+ rows.append(build_causal_tabular_features(states[: pos + 1], schema=feature_schema))
30
+ targets.append(states[pos + 1])
31
+ if not rows:
32
+ raise ValueError("No supervised rows were created")
33
+ return np.vstack(rows).astype(np.float32), np.vstack(targets).astype(np.float32)
34
+
35
+
36
+ def create_tabular_estimator(model_type: str, seed: int = 42, n_jobs: int = 1):
37
+ """Create a deterministic sklearn-compatible multi-output regressor."""
38
+ model_type = model_type.lower()
39
+ if model_type == "ridge":
40
+ from sklearn.linear_model import Ridge
41
+
42
+ return Ridge(alpha=1.0, random_state=seed)
43
+ if model_type == "ridge_cv":
44
+ from sklearn.linear_model import RidgeCV
45
+ from sklearn.multioutput import MultiOutputRegressor
46
+
47
+ base = RidgeCV(alphas=np.logspace(-4, 4, 17))
48
+ return MultiOutputRegressor(base, n_jobs=max(1, int(n_jobs)))
49
+ if model_type == "elasticnet":
50
+ from sklearn.linear_model import MultiTaskElasticNet
51
+
52
+ return MultiTaskElasticNet(alpha=0.0005, l1_ratio=0.05, random_state=seed, max_iter=5000)
53
+ if model_type == "lightgbm":
54
+ import lightgbm as lgb
55
+ from sklearn.multioutput import MultiOutputRegressor
56
+
57
+ base = lgb.LGBMRegressor(
58
+ n_estimators=300,
59
+ learning_rate=0.03,
60
+ num_leaves=31,
61
+ subsample=0.9,
62
+ colsample_bytree=0.9,
63
+ random_state=seed,
64
+ n_jobs=1,
65
+ verbose=-1,
66
+ )
67
+ return MultiOutputRegressor(base, n_jobs=max(1, int(n_jobs)))
68
+ raise ValueError(f"Unknown tabular model type: {model_type}")
src/training/sequence_trainer.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Full-sequence BPTT trainer for the causal GRU next-state model.
2
+
3
+ Trains on whole 1000-step sequences, computing a masked MSE loss only on the
4
+ scored steps (need_prediction True, i.e. current steps 100..998 -> targets
5
+ 101..999). Validation R2 is computed with a batched full-sequence forward,
6
+ which is numerically identical to the stepwise stateful replay because a GRU
7
+ is a pure recurrence.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from typing import Optional
15
+
16
+ import numpy as np
17
+ import torch
18
+ import torch.nn as nn
19
+
20
+ from src.data.protocol import get_feature_columns, load_wunder_dataframe
21
+ from src.models.sequence_models import CausalGRUForecaster, count_parameters
22
+ from src.models.tcn import CausalTCN
23
+ from src.utils.metrics import compute_r2_per_feature, compute_r2_score
24
+ from src.utils.reproducibility import set_global_seed
25
+
26
+
27
+ def load_full_sequences(parquet_path: str, seq_ids: list[int]):
28
+ """Return (states, need, seq_ids) as dense arrays.
29
+
30
+ states: (N, 1000, 32) float32
31
+ need: (N, 1000) bool (need_prediction flag per step)
32
+ """
33
+ df = load_wunder_dataframe(parquet_path, seq_ids=seq_ids)
34
+ cols = get_feature_columns(df)
35
+ states_list, need_list, ids = [], [], []
36
+ for sid, g in df.sort_values(["seq_ix", "step_in_seq"]).groupby("seq_ix", sort=True):
37
+ states_list.append(g[cols].to_numpy(dtype=np.float32))
38
+ need_list.append(g["need_prediction"].to_numpy(dtype=bool))
39
+ ids.append(int(sid))
40
+ states = np.stack(states_list).astype(np.float32)
41
+ need = np.stack(need_list)
42
+ return states, need, ids
43
+
44
+
45
+ @dataclass
46
+ class TrainConfig:
47
+ d_model: int = 256
48
+ n_layers: int = 2
49
+ dropout: float = 0.1
50
+ head_hidden: Optional[int] = None
51
+ epochs: int = 40
52
+ batch_size: int = 32
53
+ lr: float = 1.0e-3
54
+ weight_decay: float = 1.0e-4
55
+ grad_clip: float = 1.0
56
+ warmup_frac: float = 0.1
57
+ seed: int = 42
58
+ threads: int = 8
59
+ patience: int = 12
60
+ n_features: int = 32
61
+ device: str = "cpu"
62
+ rnn_type: str = "gru"
63
+ arch: str = "rnn" # "rnn" or "tcn"
64
+ kernel_size: int = 3 # tcn only
65
+
66
+
67
+ def _masked_mse(preds: torch.Tensor, states: torch.Tensor, need: torch.Tensor) -> torch.Tensor:
68
+ # preds[:, t] predicts states[:, t+1]
69
+ pred = preds[:, :-1, :]
70
+ target = states[:, 1:, :]
71
+ mask = need[:, :-1] # (B, T-1)
72
+ diff2 = (pred - target) ** 2 # (B, T-1, F)
73
+ m = mask.unsqueeze(-1).to(diff2.dtype)
74
+ return (diff2 * m).sum() / (m.sum() * preds.shape[-1])
75
+
76
+
77
+ @torch.no_grad()
78
+ def _eval_r2(model, states_t, need_t, feature_cols, batch_size=64):
79
+ model.eval()
80
+ preds_all, tgts_all = [], []
81
+ n = states_t.shape[0]
82
+ for i in range(0, n, batch_size):
83
+ sb = states_t[i : i + batch_size]
84
+ nb = need_t[i : i + batch_size]
85
+ preds, _ = model(sb)
86
+ pred = preds[:, :-1, :]
87
+ target = sb[:, 1:, :]
88
+ mask = nb[:, :-1]
89
+ preds_all.append(pred[mask].cpu().numpy())
90
+ tgts_all.append(target[mask].cpu().numpy())
91
+ y_pred = np.concatenate(preds_all).astype(np.float64)
92
+ y_true = np.concatenate(tgts_all).astype(np.float64)
93
+ mean_r2 = compute_r2_score(y_true, y_pred)
94
+ per_feat = compute_r2_per_feature(y_true, y_pred, feature_cols)
95
+ return mean_r2, per_feat, y_true, y_pred
96
+
97
+
98
+ def train_sequence_model(
99
+ data_path: str,
100
+ train_ids: list[int],
101
+ val_ids: list[int],
102
+ cfg: TrainConfig,
103
+ feature_cols: Optional[list[str]] = None,
104
+ log_every: int = 1,
105
+ verbose: bool = True,
106
+ ):
107
+ set_global_seed(cfg.seed, deterministic_torch=True, seed_torch=True)
108
+ torch.set_num_threads(int(cfg.threads))
109
+ device = torch.device(cfg.device if (cfg.device != "cuda" or torch.cuda.is_available()) else "cpu")
110
+ if str(device) != cfg.device:
111
+ print(f"requested device '{cfg.device}' unavailable; using {device}")
112
+
113
+ s_tr, n_tr, _ = load_full_sequences(data_path, train_ids)
114
+ s_va, n_va, va_ids = load_full_sequences(data_path, val_ids)
115
+ if feature_cols is None:
116
+ feature_cols = [str(i) for i in range(cfg.n_features)]
117
+
118
+ states_tr = torch.from_numpy(s_tr).to(device)
119
+ need_tr = torch.from_numpy(n_tr).to(device)
120
+ states_va = torch.from_numpy(s_va).to(device)
121
+ need_va = torch.from_numpy(n_va).to(device)
122
+
123
+ if cfg.arch == "tcn":
124
+ model = CausalTCN(
125
+ n_features=cfg.n_features, d_model=cfg.d_model, n_layers=cfg.n_layers,
126
+ kernel_size=cfg.kernel_size, dropout=cfg.dropout, head_hidden=cfg.head_hidden,
127
+ ).to(device)
128
+ else:
129
+ model = CausalGRUForecaster(
130
+ n_features=cfg.n_features, d_model=cfg.d_model, n_layers=cfg.n_layers,
131
+ dropout=cfg.dropout, head_hidden=cfg.head_hidden, rnn_type=cfg.rnn_type,
132
+ ).to(device)
133
+ if verbose:
134
+ print(f"model params: {count_parameters(model):,} train_seqs={len(train_ids)} val_seqs={len(val_ids)}")
135
+
136
+ opt = torch.optim.AdamW(model.parameters(), lr=cfg.lr, weight_decay=cfg.weight_decay)
137
+ n_batches = max(1, (states_tr.shape[0] + cfg.batch_size - 1) // cfg.batch_size)
138
+ total_steps = cfg.epochs * n_batches
139
+ sched = torch.optim.lr_scheduler.OneCycleLR(
140
+ opt, max_lr=cfg.lr, total_steps=total_steps,
141
+ pct_start=cfg.warmup_frac, anneal_strategy="cos",
142
+ )
143
+
144
+ g = torch.Generator()
145
+ g.manual_seed(cfg.seed)
146
+ best_r2 = -1e9
147
+ best_state = None
148
+ best_per_feat = None
149
+ best_oof = None
150
+ history = []
151
+ bad_epochs = 0
152
+
153
+ for epoch in range(cfg.epochs):
154
+ model.train()
155
+ perm = torch.randperm(states_tr.shape[0], generator=g)
156
+ ep_loss = 0.0
157
+ t0 = time.perf_counter()
158
+ for bi in range(n_batches):
159
+ idx = perm[bi * cfg.batch_size : (bi + 1) * cfg.batch_size]
160
+ sb = states_tr[idx]
161
+ nb = need_tr[idx]
162
+ opt.zero_grad(set_to_none=True)
163
+ preds, _ = model(sb)
164
+ loss = _masked_mse(preds, sb, nb)
165
+ loss.backward()
166
+ nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip)
167
+ opt.step()
168
+ sched.step()
169
+ ep_loss += float(loss.detach())
170
+ ep_loss /= n_batches
171
+
172
+ val_r2, per_feat, y_true, y_pred = _eval_r2(model, states_va, need_va, feature_cols)
173
+ dt = time.perf_counter() - t0
174
+ history.append({"epoch": epoch, "train_loss": ep_loss, "val_r2": val_r2, "sec": dt})
175
+ if verbose and (epoch % log_every == 0):
176
+ print(f"epoch {epoch:3d} loss={ep_loss:.5f} val_R2={val_r2:.5f} lr={sched.get_last_lr()[0]:.2e} {dt:.1f}s")
177
+
178
+ if val_r2 > best_r2:
179
+ best_r2 = val_r2
180
+ best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
181
+ best_per_feat = per_feat
182
+ best_oof = (y_true, y_pred)
183
+ bad_epochs = 0
184
+ else:
185
+ bad_epochs += 1
186
+ if bad_epochs >= cfg.patience:
187
+ if verbose:
188
+ print(f"early stop at epoch {epoch} (best val_R2={best_r2:.5f})")
189
+ break
190
+
191
+ return {
192
+ "best_val_r2": best_r2,
193
+ "best_per_feature": best_per_feat,
194
+ "best_state_dict": best_state,
195
+ "history": history,
196
+ "oof": best_oof,
197
+ "val_seq_ids": va_ids,
198
+ "feature_cols": feature_cols,
199
+ }
src/utils/__init__.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .metrics import compute_manual_r2_score, compute_r2_per_feature, compute_r2_score
2
+ from .reproducibility import make_torch_generator, seed_worker, set_global_seed
3
+ from .hardware import (
4
+ HardwareProfile,
5
+ MaxSafeConfig,
6
+ ResourceLimitError,
7
+ assert_within_max_safe,
8
+ build_hardware_profile,
9
+ calibrate_batch_size,
10
+ )
11
+
12
+ __all__ = [
13
+ 'compute_r2_score',
14
+ 'compute_r2_per_feature',
15
+ 'compute_manual_r2_score',
16
+ 'set_global_seed',
17
+ 'seed_worker',
18
+ 'make_torch_generator',
19
+ 'HardwareProfile',
20
+ 'MaxSafeConfig',
21
+ 'ResourceLimitError',
22
+ 'build_hardware_profile',
23
+ 'assert_within_max_safe',
24
+ 'calibrate_batch_size',
25
+ ]
src/utils/hardware.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hardware profiling and max-safe guard helpers for local-only experiments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import contextlib
7
+ import json
8
+ import os
9
+ import platform
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+ from dataclasses import asdict, dataclass
14
+ from pathlib import Path
15
+ from typing import Callable, Iterable, Optional
16
+
17
+
18
+ BYTES_PER_GB = 1024**3
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class MemoryInfo:
23
+ total_gb: float
24
+ available_gb: Optional[float]
25
+ used_fraction: Optional[float]
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class GpuInfo:
30
+ name: str
31
+ memory_total_mb: int
32
+ memory_used_mb: Optional[int]
33
+ temperature_c: Optional[int]
34
+ driver_version: Optional[str]
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class TorchInfo:
39
+ installed: bool
40
+ version: Optional[str]
41
+ cuda_available: bool
42
+ cuda_version: Optional[str]
43
+ device_name: Optional[str]
44
+ device_memory_gb: Optional[float]
45
+ error: Optional[str] = None
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class MaxSafeConfig:
50
+ cpu_workers: int
51
+ dataloader_workers: int
52
+ max_dataloader_workers: int
53
+ ram_limit_fraction: float
54
+ ram_target_gb: float
55
+ gpu_vram_fraction: float
56
+ gpu_target_vram_gb: Optional[float]
57
+ gpu_pause_temp_c: int
58
+ gpu_abort_temp_c: int
59
+ mixed_precision: bool
60
+ gradient_accumulation: bool
61
+ batch_candidates: list[int]
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class HardwareProfile:
66
+ profile_name: str
67
+ platform: str
68
+ python: str
69
+ cpu_name: str
70
+ logical_cpu_count: int
71
+ memory: MemoryInfo
72
+ gpus: list[GpuInfo]
73
+ torch: TorchInfo
74
+ max_safe: MaxSafeConfig
75
+
76
+
77
+ class ResourceLimitError(RuntimeError):
78
+ """Raised when a max-safe guard would abort a run."""
79
+
80
+
81
+ def _round_gb(value_bytes: int) -> float:
82
+ return round(value_bytes / BYTES_PER_GB, 2)
83
+
84
+
85
+ def get_memory_info() -> MemoryInfo:
86
+ """Return physical memory using stdlib-only platform APIs."""
87
+ if platform.system().lower() == "windows":
88
+ class MEMORYSTATUSEX(ctypes.Structure):
89
+ _fields_ = [
90
+ ("dwLength", ctypes.c_ulong),
91
+ ("dwMemoryLoad", ctypes.c_ulong),
92
+ ("ullTotalPhys", ctypes.c_ulonglong),
93
+ ("ullAvailPhys", ctypes.c_ulonglong),
94
+ ("ullTotalPageFile", ctypes.c_ulonglong),
95
+ ("ullAvailPageFile", ctypes.c_ulonglong),
96
+ ("ullTotalVirtual", ctypes.c_ulonglong),
97
+ ("ullAvailVirtual", ctypes.c_ulonglong),
98
+ ("sullAvailExtendedVirtual", ctypes.c_ulonglong),
99
+ ]
100
+
101
+ status = MEMORYSTATUSEX()
102
+ status.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
103
+ ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status))
104
+ used_fraction = 1.0 - (status.ullAvailPhys / status.ullTotalPhys)
105
+ return MemoryInfo(
106
+ total_gb=_round_gb(status.ullTotalPhys),
107
+ available_gb=_round_gb(status.ullAvailPhys),
108
+ used_fraction=round(used_fraction, 4),
109
+ )
110
+
111
+ if hasattr(os, "sysconf") and "SC_PAGE_SIZE" in os.sysconf_names:
112
+ page_size = os.sysconf("SC_PAGE_SIZE")
113
+ pages = os.sysconf("SC_PHYS_PAGES")
114
+ return MemoryInfo(total_gb=_round_gb(page_size * pages), available_gb=None, used_fraction=None)
115
+
116
+ return MemoryInfo(total_gb=0.0, available_gb=None, used_fraction=None)
117
+
118
+
119
+ def get_nvidia_gpus() -> list[GpuInfo]:
120
+ """Read NVIDIA GPU status through nvidia-smi when available."""
121
+ if not shutil.which("nvidia-smi"):
122
+ return []
123
+ cmd = [
124
+ "nvidia-smi",
125
+ "--query-gpu=name,memory.total,memory.used,temperature.gpu,driver_version",
126
+ "--format=csv,noheader,nounits",
127
+ ]
128
+ try:
129
+ raw = subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL, timeout=10)
130
+ except Exception:
131
+ return []
132
+
133
+ gpus = []
134
+ for line in raw.splitlines():
135
+ parts = [part.strip() for part in line.split(",")]
136
+ if len(parts) < 5:
137
+ continue
138
+ name, total_mb, used_mb, temp_c, driver = parts[:5]
139
+ gpus.append(
140
+ GpuInfo(
141
+ name=name,
142
+ memory_total_mb=int(float(total_mb)),
143
+ memory_used_mb=int(float(used_mb)),
144
+ temperature_c=int(float(temp_c)),
145
+ driver_version=driver,
146
+ )
147
+ )
148
+ return gpus
149
+
150
+
151
+ def get_torch_info() -> TorchInfo:
152
+ """Return PyTorch/CUDA availability without making Torch mandatory."""
153
+ try:
154
+ with open(os.devnull, "w", encoding="utf-8") as devnull, contextlib.redirect_stderr(devnull):
155
+ import torch
156
+
157
+ cuda_available = bool(torch.cuda.is_available())
158
+ device_name = None
159
+ device_memory_gb = None
160
+ if cuda_available:
161
+ props = torch.cuda.get_device_properties(0)
162
+ device_name = torch.cuda.get_device_name(0)
163
+ device_memory_gb = round(props.total_memory / BYTES_PER_GB, 2)
164
+ return TorchInfo(
165
+ installed=True,
166
+ version=str(torch.__version__),
167
+ cuda_available=cuda_available,
168
+ cuda_version=getattr(torch.version, "cuda", None),
169
+ device_name=device_name,
170
+ device_memory_gb=device_memory_gb,
171
+ )
172
+ except Exception as exc:
173
+ return TorchInfo(
174
+ installed=False,
175
+ version=None,
176
+ cuda_available=False,
177
+ cuda_version=None,
178
+ device_name=None,
179
+ device_memory_gb=None,
180
+ error=repr(exc),
181
+ )
182
+
183
+
184
+ def build_max_safe_config(memory: MemoryInfo, gpus: list[GpuInfo]) -> MaxSafeConfig:
185
+ """Choose conservative max-safe defaults for this laptop class."""
186
+ logical = os.cpu_count() or 1
187
+ cpu_workers = max(1, min(12, logical - 4 if logical > 4 else logical))
188
+ gpu_target_vram_gb = None
189
+ if gpus:
190
+ gpu_target_vram_gb = round((gpus[0].memory_total_mb / 1024) * 0.85, 2)
191
+ return MaxSafeConfig(
192
+ cpu_workers=cpu_workers,
193
+ dataloader_workers=2,
194
+ max_dataloader_workers=4,
195
+ ram_limit_fraction=0.90,
196
+ ram_target_gb=min(13.0, round(memory.total_gb * 0.82, 2)) if memory.total_gb else 13.0,
197
+ gpu_vram_fraction=0.85,
198
+ gpu_target_vram_gb=gpu_target_vram_gb,
199
+ gpu_pause_temp_c=82,
200
+ gpu_abort_temp_c=87,
201
+ mixed_precision=True,
202
+ gradient_accumulation=True,
203
+ batch_candidates=[512, 256, 128, 64, 32, 16, 8],
204
+ )
205
+
206
+
207
+ def build_hardware_profile(profile_name: str = "max_safe") -> HardwareProfile:
208
+ memory = get_memory_info()
209
+ gpus = get_nvidia_gpus()
210
+ return HardwareProfile(
211
+ profile_name=profile_name,
212
+ platform=platform.platform(),
213
+ python=platform.python_version(),
214
+ cpu_name=platform.processor() or platform.machine(),
215
+ logical_cpu_count=os.cpu_count() or 1,
216
+ memory=memory,
217
+ gpus=gpus,
218
+ torch=get_torch_info(),
219
+ max_safe=build_max_safe_config(memory, gpus),
220
+ )
221
+
222
+
223
+ def assert_within_max_safe(profile: HardwareProfile) -> None:
224
+ """Abort before a run if current memory/temperature already violates guardrails."""
225
+ if profile.memory.used_fraction is not None and profile.memory.used_fraction >= profile.max_safe.ram_limit_fraction:
226
+ raise ResourceLimitError(
227
+ f"System RAM usage {profile.memory.used_fraction:.1%} exceeds "
228
+ f"{profile.max_safe.ram_limit_fraction:.0%} guard"
229
+ )
230
+ if profile.gpus:
231
+ gpu = profile.gpus[0]
232
+ if gpu.temperature_c is not None and gpu.temperature_c >= profile.max_safe.gpu_abort_temp_c:
233
+ raise ResourceLimitError(
234
+ f"GPU temperature {gpu.temperature_c}C exceeds "
235
+ f"{profile.max_safe.gpu_abort_temp_c}C abort guard"
236
+ )
237
+
238
+
239
+ def calibrate_batch_size(
240
+ trial_fn: Callable[[int], None],
241
+ candidates: Iterable[int],
242
+ *,
243
+ repeats: int = 2,
244
+ ) -> Optional[int]:
245
+ """
246
+ Choose the largest candidate batch that survives repeated trial calls.
247
+
248
+ `trial_fn` should raise RuntimeError for OOM-like failures. This helper is
249
+ intentionally framework-agnostic so tests can simulate OOM without Torch.
250
+ """
251
+ for batch_size in candidates:
252
+ try:
253
+ for _ in range(repeats):
254
+ trial_fn(int(batch_size))
255
+ return int(batch_size)
256
+ except RuntimeError:
257
+ continue
258
+ return None
259
+
260
+
261
+ def atomic_write_json(path: str | Path, payload: object) -> None:
262
+ """Write JSON atomically so interrupted runs do not corrupt manifests."""
263
+ path = Path(path)
264
+ path.parent.mkdir(parents=True, exist_ok=True)
265
+ if hasattr(payload, "__dataclass_fields__"):
266
+ payload = asdict(payload)
267
+ with tempfile.NamedTemporaryFile("w", delete=False, dir=str(path.parent), encoding="utf-8") as tmp:
268
+ json.dump(payload, tmp, indent=2, sort_keys=True)
269
+ tmp.write("\n")
270
+ tmp_path = Path(tmp.name)
271
+ tmp_path.replace(path)
272
+
273
+
274
+ def dataclass_to_dict(value: object) -> dict:
275
+ if hasattr(value, "__dataclass_fields__"):
276
+ return asdict(value)
277
+ if isinstance(value, dict):
278
+ return value
279
+ raise TypeError(f"Unsupported JSON payload: {type(value)}")
src/utils/metrics.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """R2 metrics for the Wunder Fund RNN Challenge.
2
+
3
+ The authoritative local metric mirrors ``competition_package.utils``:
4
+ ``sklearn.metrics.r2_score`` is computed independently per feature and then
5
+ averaged.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ from sklearn.metrics import r2_score
12
+
13
+
14
+ def _validate_shapes(y_true: np.ndarray, y_pred: np.ndarray) -> None:
15
+ if y_true.shape != y_pred.shape:
16
+ raise ValueError(
17
+ f"Shape mismatch: y_true {y_true.shape} vs y_pred {y_pred.shape}"
18
+ )
19
+ if y_true.ndim != 2:
20
+ raise ValueError(f"Expected 2D arrays, got y_true.ndim={y_true.ndim}")
21
+
22
+
23
+ def compute_r2_score(y_true: np.ndarray, y_pred: np.ndarray) -> float:
24
+ """Compute mean per-feature R2 using sklearn, matching the starter pack."""
25
+ return float(np.mean(list(compute_r2_per_feature(y_true, y_pred).values())))
26
+
27
+
28
+ def compute_r2_per_feature(
29
+ y_true: np.ndarray,
30
+ y_pred: np.ndarray,
31
+ feature_names: list[str] | None = None,
32
+ ) -> dict[str, float]:
33
+ """Compute sklearn R2 independently for each output feature."""
34
+ _validate_shapes(y_true, y_pred)
35
+ n_features = y_true.shape[1]
36
+ if feature_names is None:
37
+ feature_names = [f"feature_{i}" for i in range(n_features)]
38
+ if len(feature_names) != n_features:
39
+ raise ValueError("feature_names length must match prediction width")
40
+
41
+ return {
42
+ feature_names[i]: float(r2_score(y_true[:, i], y_pred[:, i]))
43
+ for i in range(n_features)
44
+ }
45
+
46
+
47
+ def compute_manual_r2_score(
48
+ y_true: np.ndarray,
49
+ y_pred: np.ndarray,
50
+ epsilon: float = 1e-8,
51
+ ) -> float:
52
+ """Legacy epsilon-stabilized R2 helper for diagnostics only."""
53
+ _validate_shapes(y_true, y_pred)
54
+ scores = []
55
+ for i in range(y_true.shape[1]):
56
+ ss_res = np.sum((y_true[:, i] - y_pred[:, i]) ** 2)
57
+ ss_tot = np.sum((y_true[:, i] - y_true[:, i].mean()) ** 2)
58
+ scores.append(1 - (ss_res / (ss_tot + epsilon)))
59
+ return float(np.mean(scores))
src/utils/reproducibility.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic runtime helpers for Phase 2 experiments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import random
7
+ from typing import Optional
8
+
9
+ import numpy as np
10
+
11
+
12
+ def set_global_seed(seed: int, deterministic_torch: bool = True, seed_torch: bool = True) -> None:
13
+ """Seed Python, NumPy, and PyTorch when available."""
14
+ os.environ["PYTHONHASHSEED"] = str(seed)
15
+ random.seed(seed)
16
+ np.random.seed(seed)
17
+
18
+ if not seed_torch:
19
+ return
20
+
21
+ try:
22
+ import torch
23
+
24
+ torch.manual_seed(seed)
25
+ if torch.cuda.is_available():
26
+ torch.cuda.manual_seed_all(seed)
27
+ if deterministic_torch:
28
+ torch.use_deterministic_algorithms(True, warn_only=True)
29
+ torch.backends.cudnn.benchmark = False
30
+ torch.backends.cudnn.deterministic = True
31
+ except ImportError:
32
+ return
33
+
34
+
35
+ def seed_worker(worker_id: int) -> None:
36
+ """Seed DataLoader workers from PyTorch's worker seed."""
37
+ try:
38
+ import torch
39
+
40
+ worker_seed = torch.initial_seed() % 2**32
41
+ except ImportError:
42
+ worker_seed = worker_id
43
+ np.random.seed(worker_seed)
44
+ random.seed(worker_seed)
45
+
46
+
47
+ def make_torch_generator(seed: int) -> Optional[object]:
48
+ """Return a seeded torch.Generator, or None if torch is unavailable."""
49
+ try:
50
+ import torch
51
+
52
+ generator = torch.Generator()
53
+ generator.manual_seed(seed)
54
+ return generator
55
+ except ImportError:
56
+ return None