1
- # Third-Party Imports
1
+ # Python imports
2
2
import time
3
+ from typing import Optional , TYPE_CHECKING
3
4
5
+ # Third-Party Imports
4
6
import numpy
5
7
import scipy .stats
6
8
7
9
# PyCSEP imports
8
10
from csep .core .exceptions import CSEPEvaluationException
11
+ from csep .core .catalogs import CSEPCatalog
9
12
from csep .models import (
10
13
CatalogNumberTestResult ,
11
14
CatalogSpatialTestResult ,
14
17
CalibrationTestResult
15
18
)
16
19
from csep .utils .calc import _compute_likelihood
17
- from csep .utils .stats import get_quantiles , cumulative_square_diff
20
+ from csep .utils .stats import get_quantiles , cumulative_square_diff , MLL_score
21
+
22
+ if TYPE_CHECKING :
23
+ from csep .core .forecasts import CatalogForecast
18
24
19
25
20
26
def number_test (forecast , observed_catalog , verbose = True ):
@@ -55,6 +61,7 @@ def number_test(forecast, observed_catalog, verbose=True):
55
61
obs_name = observed_catalog .name )
56
62
return result
57
63
64
+
58
65
def spatial_test (forecast , observed_catalog , verbose = True ):
59
66
""" Performs spatial test for catalog-based forecasts.
60
67
@@ -140,6 +147,7 @@ def spatial_test(forecast, observed_catalog, verbose=True):
140
147
141
148
return result
142
149
150
+
143
151
def magnitude_test (forecast , observed_catalog , verbose = True ):
144
152
""" Performs magnitude test for catalog-based forecasts """
145
153
test_distribution = []
@@ -215,6 +223,7 @@ def magnitude_test(forecast, observed_catalog, verbose=True):
215
223
216
224
return result
217
225
226
+
218
227
def pseudolikelihood_test (forecast , observed_catalog , verbose = True ):
219
228
""" Performs the spatial pseudolikelihood test for catalog forecasts.
220
229
@@ -310,6 +319,7 @@ def pseudolikelihood_test(forecast, observed_catalog, verbose=True):
310
319
311
320
return result
312
321
322
+
313
323
def calibration_test (evaluation_results , delta_1 = False ):
314
324
""" Perform the calibration test by computing a Kilmogorov-Smirnov test of the observed quantiles against a uniform
315
325
distribution.
@@ -345,3 +355,261 @@ def calibration_test(evaluation_results, delta_1=False):
345
355
return result
346
356
347
357
358
+ def resampled_magnitude_test (forecast : "CatalogForecast" ,
359
+ observed_catalog : CSEPCatalog ,
360
+ verbose : bool = False ,
361
+ seed : Optional [int ] = None ) -> CatalogMagnitudeTestResult :
362
+ """
363
+ Performs the resampled magnitude test for catalog-based forecasts (Serafini et al., 2024),
364
+ which corrects the bias from the original M-test implementation to the total N of events.
365
+ Calculates the (pseudo log-likelihood) test statistic distribution from the forecast's
366
+ synthetic catalogs Λ_j as:
367
+
368
+ D_j = Σ_k [log(Λ_u(k) * N / N_u + 1) - log(Λ̃_j(k) + 1)] ^ 2
369
+
370
+ where k are the magnitude bins, Λ_u the union of all synthetic catalogs, N_u the total
371
+ number of events in Λ_u, and Λ̃_j the resampled catalog containing exactly N events.
372
+
373
+ The pseudo log-likelihood statistic from the observations is calculated as:
374
+
375
+ D_o = Σ_k [log(Λ_U(k) * N / N_u + 1) - log(Ω(k) + 1)]^2
376
+
377
+ where Ω is the observed catalog.
378
+
379
+ Args:
380
+ forecast (CatalogForecast): The forecast to be evaluated
381
+ observed_catalog (CSEPCatalog): The observation/testing catalog.
382
+ verbose (bool): Flag to display debug messages
383
+ seed (int): Random number generator seed
384
+
385
+ Returns:
386
+ A CatalogMagnitudeTestResult object containing the statistic distribution and the
387
+ observed statistic.
388
+ """
389
+
390
+ # set seed
391
+ if seed :
392
+ numpy .random .seed (seed )
393
+ """ """
394
+ test_distribution = []
395
+
396
+ if forecast .region .magnitudes is None :
397
+ raise CSEPEvaluationException (
398
+ "Forecast must have region.magnitudes member to perform magnitude test." )
399
+
400
+ # short-circuit if zero events
401
+ if observed_catalog .event_count == 0 :
402
+ print ("Cannot perform magnitude test when observed event count is zero." )
403
+ # prepare result
404
+ result = CatalogMagnitudeTestResult (test_distribution = test_distribution ,
405
+ name = 'M-Test' ,
406
+ observed_statistic = None ,
407
+ quantile = (None , None ),
408
+ status = 'not-valid' ,
409
+ min_mw = forecast .min_magnitude ,
410
+ obs_catalog_repr = str (observed_catalog ),
411
+ obs_name = observed_catalog .name ,
412
+ sim_name = forecast .name )
413
+
414
+ return result
415
+
416
+ # compute expected rates for forecast if needed
417
+ if forecast .expected_rates is None :
418
+ forecast .get_expected_rates (verbose = verbose )
419
+
420
+ # THIS IS NEW - returns the average events in the magnitude bins
421
+ union_histogram = numpy .zeros (len (forecast .magnitudes ))
422
+ for j , cat in enumerate (forecast ):
423
+ union_histogram += cat .magnitude_counts ()
424
+
425
+ mag_half_bin = numpy .diff (observed_catalog .region .magnitudes )[0 ] / 2.
426
+ # end new
427
+ n_union_events = numpy .sum (union_histogram )
428
+ obs_histogram = observed_catalog .magnitude_counts ()
429
+ n_obs = numpy .sum (obs_histogram )
430
+ union_scale = n_obs / n_union_events
431
+ scaled_union_histogram = union_histogram * union_scale
432
+
433
+ # this is new - prob to be used for resampling
434
+ probs = union_histogram / n_union_events
435
+ # end new
436
+
437
+ # compute the test statistic for each catalog
438
+ t0 = time .time ()
439
+ for i , catalog in enumerate (forecast ):
440
+ # THIS IS NEW - sampled from the union forecast histogram
441
+ mag_values = numpy .random .choice (forecast .magnitudes + mag_half_bin , p = probs ,
442
+ size = int (n_obs ))
443
+ extended_mag_max = max (forecast .magnitudes ) + 10
444
+ mag_counts , tmp = numpy .histogram (mag_values , bins = numpy .append (forecast .magnitudes ,
445
+ extended_mag_max ))
446
+ # end new
447
+ n_events = numpy .sum (mag_counts )
448
+ if n_events == 0 :
449
+ # print("Skipping to next because catalog contained zero events.")
450
+ continue
451
+ scale = n_obs / n_events
452
+ catalog_histogram = mag_counts * scale
453
+ # compute magnitude test statistic for the catalog
454
+ test_distribution .append (
455
+ cumulative_square_diff (numpy .log10 (catalog_histogram + 1 ),
456
+ numpy .log10 (scaled_union_histogram + 1 ))
457
+ )
458
+ # output status
459
+ if verbose :
460
+ tens_exp = numpy .floor (numpy .log10 (i + 1 ))
461
+ if (i + 1 ) % 10 ** tens_exp == 0 :
462
+ t1 = time .time ()
463
+ print (f'Processed { i + 1 } catalogs in { t1 - t0 } seconds' , flush = True )
464
+
465
+ # compute observed statistic
466
+ obs_d_statistic = cumulative_square_diff (numpy .log10 (obs_histogram + 1 ),
467
+ numpy .log10 (scaled_union_histogram + 1 ))
468
+
469
+ # score evaluation
470
+ delta_1 , delta_2 = get_quantiles (test_distribution , obs_d_statistic )
471
+
472
+ # prepare result
473
+ result = CatalogMagnitudeTestResult (test_distribution = test_distribution ,
474
+ name = 'M-Test' ,
475
+ observed_statistic = obs_d_statistic ,
476
+ quantile = (delta_1 , delta_2 ),
477
+ status = 'normal' ,
478
+ min_mw = forecast .min_magnitude ,
479
+ obs_catalog_repr = str (observed_catalog ),
480
+ obs_name = observed_catalog .name ,
481
+ sim_name = forecast .name )
482
+
483
+ return result
484
+
485
+
486
+ def MLL_magnitude_test (forecast : "CatalogForecast" ,
487
+ observed_catalog : CSEPCatalog ,
488
+ full_calculation : bool = False ,
489
+ verbose : bool = False ,
490
+ seed : Optional [int ] = None ) -> CatalogMagnitudeTestResult :
491
+ """
492
+ Implements the modified Multinomial log-likelihood ratio (MLL) magnitude test (Serafini et
493
+ al., 2024). Calculates the test statistic distribution as:
494
+
495
+ D̃_j = -2 * log( L(Λ_u + N_u / N_j + Λ̃_j + 1) /
496
+ [L(Λ_u + N_u / N_j) * L(Λ̃_j + 1)]
497
+ )
498
+
499
+ where L is the multinomial likelihood function, Λ_u the union of all the forecasts'
500
+ synthetic catalogs, N_u the total number of events in Λ_u, Λ̃_j the resampled catalog
501
+ containing exactly N observed events. The observed statistic is defined as:
502
+
503
+ D_o = -2 * log( L(Λ_u + N_u / N + Ω + 1) /
504
+ [L(Λ_u + N_u / N) * L(Ω + 1)]
505
+ )
506
+
507
+ where Ω is the observed catalog.
508
+
509
+ Args:
510
+ forecast (CatalogForecast): The forecast to be evaluated
511
+ observed_catalog (CSEPCatalog): The observation/testing catalog.
512
+ full_calculation (bool): Whether to sample from the entire stochastic catalogs or from
513
+ its already processed magnitude histogram.
514
+ verbose (bool): Flag to display debug messages
515
+ seed (int): Random number generator seed
516
+
517
+ Returns:
518
+ A CatalogMagnitudeTestResult object containing the statistic distribution and the
519
+ observed statistic.
520
+ """
521
+
522
+ # set seed
523
+ if seed :
524
+ numpy .random .seed (seed )
525
+
526
+ test_distribution = []
527
+
528
+ if forecast .region .magnitudes is None :
529
+ raise CSEPEvaluationException (
530
+ "Forecast must have region.magnitudes member to perform magnitude test." )
531
+
532
+ # short-circuit if zero events
533
+ if observed_catalog .event_count == 0 :
534
+ print ("Cannot perform magnitude test when observed event count is zero." )
535
+ # prepare result
536
+ result = CatalogMagnitudeTestResult (test_distribution = test_distribution ,
537
+ name = 'M-Test' ,
538
+ observed_statistic = None ,
539
+ quantile = (None , None ),
540
+ status = 'not-valid' ,
541
+ min_mw = forecast .min_magnitude ,
542
+ obs_catalog_repr = str (observed_catalog ),
543
+ obs_name = observed_catalog .name ,
544
+ sim_name = forecast .name )
545
+
546
+ return result
547
+
548
+ # compute expected rates for forecast if needed
549
+ if forecast .expected_rates is None :
550
+ forecast .get_expected_rates (verbose = verbose )
551
+
552
+ # calculate histograms of union forecast and total number of events
553
+ Lambda_u_histogram = numpy .zeros (len (forecast .magnitudes ))
554
+
555
+ if full_calculation :
556
+ Lambda_u = []
557
+ else :
558
+ mag_half_bin = numpy .diff (observed_catalog .region .magnitudes )[0 ] / 2.
559
+
560
+ for j , cat in enumerate (forecast ):
561
+ if full_calculation :
562
+ Lambda_u = numpy .append (Lambda_u , cat .get_magnitudes ())
563
+ Lambda_u_histogram += cat .magnitude_counts ()
564
+
565
+ # # calculate histograms of observations and observed number of events
566
+ Omega_histogram = observed_catalog .magnitude_counts ()
567
+ n_obs = numpy .sum (Omega_histogram )
568
+
569
+ # compute observed statistic
570
+ obs_d_statistic = MLL_score (union_catalog_counts = Lambda_u_histogram ,
571
+ catalog_counts = Omega_histogram )
572
+
573
+ probs = Lambda_u_histogram / numpy .sum (Lambda_u_histogram )
574
+
575
+ # compute the test statistic for each catalog
576
+ t0 = time .time ()
577
+ for i , catalog in enumerate (forecast ):
578
+ # this is new - sampled from the union forecast histogram
579
+ if full_calculation :
580
+ mag_values = numpy .random .choice (Lambda_u , size = int (n_obs ))
581
+ else :
582
+ mag_values = numpy .random .choice (forecast .magnitudes + mag_half_bin , p = probs ,
583
+ size = int (n_obs ))
584
+ extended_mag_max = max (forecast .magnitudes ) + 10
585
+ Lambda_j_histogram , tmp = numpy .histogram (mag_values ,
586
+ bins = numpy .append (forecast .magnitudes ,
587
+ extended_mag_max ))
588
+
589
+ # compute magnitude test statistic for the catalog
590
+ test_distribution .append (
591
+ MLL_score (union_catalog_counts = Lambda_u_histogram ,
592
+ catalog_counts = Lambda_j_histogram )
593
+ )
594
+ # output status
595
+ if verbose :
596
+ tens_exp = numpy .floor (numpy .log10 (i + 1 ))
597
+ if (i + 1 ) % 10 ** tens_exp == 0 :
598
+ t1 = time .time ()
599
+ print (f'Processed { i + 1 } catalogs in { t1 - t0 } seconds' , flush = True )
600
+
601
+ # score evaluation
602
+ delta_1 , delta_2 = get_quantiles (test_distribution , obs_d_statistic )
603
+
604
+ # prepare result
605
+ result = CatalogMagnitudeTestResult (test_distribution = test_distribution ,
606
+ name = 'M-Test' ,
607
+ observed_statistic = obs_d_statistic ,
608
+ quantile = (delta_1 , delta_2 ),
609
+ status = 'normal' ,
610
+ min_mw = forecast .min_magnitude ,
611
+ obs_catalog_repr = str (observed_catalog ),
612
+ obs_name = observed_catalog .name ,
613
+ sim_name = forecast .name )
614
+
615
+ return result
0 commit comments