Source code for openquake.hazardlib.correlation_models.base

# The Hazard Library
# Copyright (C) 2012-2026 GEM Foundation
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Base interfaces shared by ground-motion correlation models."""

from dataclasses import dataclass
from enum import Enum
from typing import Protocol, runtime_checkable

import numpy
from scipy import stats

from openquake.hazardlib import const
from openquake.hazardlib.correlation_utils import corr_clipped, cov_nearest
from openquake.hazardlib.imt import IMT
from openquake.hazardlib.truncated_mvn import TruncatedMVN


[docs]class ResidualComponent(str, Enum): """Residual components for which a model can be calibrated.""" TOTAL = 'total' WITHIN_EVENT = 'within' BETWEEN_EVENT = 'between'
[docs]@dataclass(frozen=True) class CorrelationContext: """Optional predictors used by context-dependent correlation models.""" mag: float | None = None rake: float | None = None trt: str | None = None region: str | None = None
[docs]@runtime_checkable class CorrelationFactor(Protocol): """A factorization capable of applying correlation to samples."""
[docs] def apply(self, samples: numpy.ndarray) -> numpy.ndarray: """Apply the factorization to standard-normal samples."""
[docs]@dataclass(frozen=True) class CholeskyFactor: """Dense Cholesky factorization used by the default implementation.""" lower_triangle: numpy.ndarray
[docs] def apply(self, samples): return self.lower_triangle @ samples
[docs]class CorrelationModel: """Common metadata and validation for all correlation models.""" DEFINED_FOR_RESIDUAL_COMPONENT = None DEFINED_FOR_INTENSITY_MEASURE_TYPES = None DEFINED_FOR_INTENSITY_MEASURE_COMPONENT = None CALIBRATED_FOR_INTENSITY_MEASURE_TYPES = None INTENSITY_MEASURE_TYPE_APPROXIMATIONS = {} DEFINED_FOR_SA_DAMPING = None DEFINED_FOR_SA_PERIOD_RANGE = None DEFINED_FOR_REGION = None
[docs] def validate(self): """Validate model parameters after construction.""" imc = self.DEFINED_FOR_INTENSITY_MEASURE_COMPONENT if imc is not None and not isinstance(imc, const.IMC): raise TypeError( f'{self.__class__.__name__}.' 'DEFINED_FOR_INTENSITY_MEASURE_COMPONENT must be an ' 'openquake.hazardlib.const.IMC member') supported = self.DEFINED_FOR_INTENSITY_MEASURE_TYPES calibrated = self._calibrated_imts() approximations = self.INTENSITY_MEASURE_TYPE_APPROXIMATIONS if supported is not None and not calibrated <= supported: raise ValueError( f'{self.__class__.__name__} declares calibrated intensity ' 'measure types that it does not support') if supported is not None and not approximations.keys() <= supported: raise ValueError( f'{self.__class__.__name__} declares approximations for ' 'unsupported intensity measure types') if not all(isinstance(imt, IMT) for imt in approximations.values()): raise TypeError( f'{self.__class__.__name__} intensity measure type ' 'approximations must map to IMT instances')
[docs] def validate_imts(self, imts): """Raise when an IMT is outside the model's declared scope.""" model_name = self.__class__.__name__ supported = self.DEFINED_FOR_INTENSITY_MEASURE_TYPES if supported is not None: supported_names = {imt_type.__name__ for imt_type in supported} unsupported = sorted({ imt.name for imt in imts if imt.name not in supported_names}) if unsupported: raise ValueError( f'{model_name} does not support ' f'{", ".join(unsupported)}') for imt in imts: damping = self.DEFINED_FOR_SA_DAMPING if (imt.name == 'SA' and damping is not None and imt.damping != damping): raise ValueError( f'{model_name} supports only ' f'{damping:g}%-damped SA') period_range = self.DEFINED_FOR_SA_PERIOD_RANGE if imt.name == 'SA' and period_range is not None: minimum, maximum = period_range if not minimum <= imt.period <= maximum: raise ValueError( f'{model_name} supports {imt.name} periods from ' f'{minimum:g} to {maximum:g} s, not ' f'{imt.period:g} s') self._validate_imt_combination(imts)
def _validate_imt_combination(self, imts): """Validate restrictions involving more than one IMT.""" @staticmethod def _validate_distances(sites_or_distances): """Return a finite, non-negative distance matrix in float64.""" if hasattr(sites_or_distances, 'mesh'): distances = sites_or_distances.mesh.get_distance_matrix() else: distances = sites_or_distances distances = numpy.asarray(distances, dtype=numpy.float64) if distances.ndim != 2: raise ValueError('Distances must be a two-dimensional matrix') if not numpy.all(numpy.isfinite(distances)): raise ValueError('Distances must be finite') if numpy.any(distances < 0): raise ValueError('Distances must be non-negative') return distances @staticmethod def _validate_block(block, expected_shape, check_bounds=True): """Validate the shape and values returned by a model.""" block = numpy.asarray(block) if block.shape != expected_shape: raise ValueError( f'Expected a correlation block with shape {expected_shape}, ' f'got {block.shape}') if not numpy.all(numpy.isfinite(block)): raise ValueError('Correlation values must be finite') if (check_bounds and numpy.any(numpy.abs(block) > 1 + 1E-12)): raise ValueError('Correlation values must be between -1 and 1') return block @classmethod def _calibrated_imts(cls): calibrated = cls.CALIBRATED_FOR_INTENSITY_MEASURE_TYPES if calibrated is None: return cls.DEFINED_FOR_INTENSITY_MEASURE_TYPES return calibrated def _get_component(self, component=None): if component is None: return self.DEFINED_FOR_RESIDUAL_COMPONENT try: component = ResidualComponent(component) except ValueError as exc: allowed = ', '.join(item.value for item in ResidualComponent) raise ValueError( f'Unknown residual component {component!r}; use {allowed}' ) from exc defined_component = self.DEFINED_FOR_RESIDUAL_COMPONENT if (defined_component is not None and component != defined_component): raise ValueError( f'{self.__class__.__name__} provides ' f'{defined_component.value} correlation, not ' f'{component.value}') return component
[docs]class SpatialCrossIMTCorrelationModel(CorrelationModel): """Correlation over a joint, IMT-major vector of sites and IMTs."""
[docs] def correlation_block(self, distances, imts1, imts2=None, component=None, context=None): """Return correlation between two IMT-major site vectors. ``distances`` has shape ``(N1, N2)`` and the returned matrix has shape ``(len(imts1) * N1, len(imts2) * N2)``. Joint models should implement this method so they can also be used for conditioning. """ self._get_component(component) if imts2 is None: imts2 = imts1 self.validate_imts(imts1) self.validate_imts(imts2) distances = self._validate_distances(distances) block = self._correlation_block( distances, imts1, imts2, context=context) expected = (len(imts1) * distances.shape[0], len(imts2) * distances.shape[1]) return self._validate_block(block, expected)
def _correlation_block(self, distances, imts1, imts2, context=None): """Implement :meth:`correlation_block` in a concrete model.""" raise NotImplementedError
[docs] def covariance(self, sites, imts, component=None, context=None): """Return a covariance matrix with shape ``(M*N, M*N)``.""" distances = sites.mesh.get_distance_matrix() return self.correlation_block( distances, imts, component=component, context=context)
[docs] def factor(self, sites, imts, component=None, context=None, ensure_psd=True): """Return the default dense factorization of :meth:`covariance`. The fast path attempts Cholesky decomposition directly. A covariance repair is performed only when decomposition fails and ``ensure_psd`` is true. Models with efficient structured factorizations should override this method. """ covariance = numpy.asarray( self.covariance(sites, imts, component, context)) if covariance.ndim != 2 or covariance.shape[0] != covariance.shape[1]: raise ValueError('A correlation matrix must be square') self._validate_block( covariance, covariance.shape, check_bounds=False) if not numpy.allclose(covariance, covariance.T): raise ValueError('A correlation matrix must be symmetric') if not numpy.allclose(numpy.diag(covariance), 1): raise ValueError('A correlation matrix must have a unit diagonal') try: lower_triangle = numpy.linalg.cholesky(covariance) except numpy.linalg.LinAlgError: if not ensure_psd: raise covariance = cov_nearest(covariance, threshold=1E-12) lower_triangle = numpy.linalg.cholesky(covariance) return CholeskyFactor(lower_triangle)
[docs] def correlate(self, sites, imts, samples, component=None, context=None): """Correlate standard-normal samples across IMTs and sites. ``samples`` must have shape ``(M, N, E)``. The first two dimensions are flattened in IMT-major order before applying the factorization. """ samples = numpy.asarray(samples) expected = (len(imts), len(sites)) if samples.ndim != 3 or samples.shape[:2] != expected: raise ValueError( f'Expected samples with shape {expected} + (E,), got ' f'{samples.shape}') factor = self.factor(sites, imts, component, context) correlated = factor.apply(samples.reshape(-1, samples.shape[-1])) return correlated.reshape(samples.shape)
[docs]class SpatialCorrelationModel(SpatialCrossIMTCorrelationModel): """Same-IMT spatial correlation over a collection of sites.""" def __init__(self): self.cache = {}
[docs] def correlation_matrix(self, sites, imt, component=None, context=None): """Return a same-IMT spatial correlation matrix or block.""" self._get_component(component) self.validate_imts([imt]) distances = self._validate_distances(sites) implementation = type(self)._correlation_matrix if implementation is not SpatialCorrelationModel._correlation_matrix: matrix = implementation(self, distances, imt, context) else: legacy = type(self)._get_correlation_matrix if legacy is SpatialCorrelationModel._get_correlation_matrix: raise NotImplementedError matrix = legacy(self, distances, imt) matrix = self._validate_block(matrix, distances.shape) is_self_distance = ( distances.shape[0] == distances.shape[1] and numpy.allclose(distances, distances.T) and numpy.allclose(numpy.diag(distances), 0)) if is_self_distance: if not numpy.allclose(matrix, matrix.T): raise ValueError('A correlation matrix must be symmetric') if not numpy.allclose(numpy.diag(matrix), 1): raise ValueError( 'A correlation matrix must have a unit diagonal') return matrix
def _correlation_matrix(self, distances, imt, context=None): """Implement :meth:`correlation_matrix` in a concrete model.""" legacy = type(self)._get_correlation_matrix if legacy is SpatialCorrelationModel._get_correlation_matrix: raise NotImplementedError return legacy(self, distances, imt) def _get_correlation_matrix(self, sites, imt): return self.correlation_matrix(sites, imt)
[docs] def get_lower_triangle_correlation_matrix(self, sites, imt): """Return the dense Cholesky factor of the correlation matrix.""" return numpy.linalg.cholesky( self.correlation_matrix(sites, imt))
[docs] def apply_correlation(self, sites, imt, residuals, stddev_intra=0): """Apply spatial correlation to sampled within-event residuals.""" try: lower_triangle = self.cache[imt] except KeyError: lower_triangle = self.get_lower_triangle_correlation_matrix( sites.complete, imt) self.cache[imt] = lower_triangle num_complete = len(sites.complete) if len(sites) < num_complete: complete = numpy.zeros((num_complete, residuals.shape[1])) complete[sites.sids] = residuals return (lower_triangle @ complete)[sites.sids, :] return lower_triangle @ residuals
[docs] def covariance(self, sites, imts, component=None, context=None): """Embed same-IMT matrices in IMT-major diagonal blocks.""" num_sites = len(sites) covariance = numpy.zeros( (len(imts) * num_sites, len(imts) * num_sites)) for imt_index, imt in enumerate(imts): start = imt_index * num_sites block = slice(start, start + num_sites) covariance[block, block] = self.correlation_matrix( sites, imt, component, context) return covariance
def _correlation_block(self, distances, imts1, imts2, context=None): """Return same-IMT spatial blocks for two site vectors.""" num_sites1, num_sites2 = distances.shape correlation = numpy.zeros( (len(imts1) * num_sites1, len(imts2) * num_sites2)) for index1, imt1 in enumerate(imts1): for index2, imt2 in enumerate(imts2): if imt1 != imt2: continue rows = slice(index1 * num_sites1, (index1 + 1) * num_sites1) cols = slice(index2 * num_sites2, (index2 + 1) * num_sites2) correlation[rows, cols] = self._correlation_matrix( distances, imt1, context) return correlation
[docs]class CrossIMTCorrelationModel(SpatialCrossIMTCorrelationModel): """Cross-IMT correlation at a single site."""
[docs] def rho(self, from_imt, to_imt, component=None, context=None): """Return the correlation between two IMTs.""" self._get_component(component) self.validate_imts([from_imt, to_imt]) implementation = type(self)._rho if implementation is not CrossIMTCorrelationModel._rho: correlation = implementation( self, from_imt, to_imt, context=context) else: legacy = type(self).get_correlation if legacy is CrossIMTCorrelationModel.get_correlation: raise NotImplementedError correlation = legacy(self, from_imt, to_imt) if not numpy.isscalar(correlation) or not numpy.isfinite(correlation): raise ValueError('A correlation coefficient must be finite') if abs(correlation) > 1 + 1E-12: raise ValueError( 'A correlation coefficient must be between -1 and 1') return correlation
def _rho(self, from_imt, to_imt, context=None): """Implement :meth:`rho` in a concrete model.""" legacy = type(self).get_correlation if legacy is CrossIMTCorrelationModel.get_correlation: raise NotImplementedError return legacy(self, from_imt, to_imt)
[docs] def get_correlation(self, from_imt, to_imt): """Compatibility alias for :meth:`rho`.""" return self.rho(from_imt, to_imt)
[docs] def correlation_matrix(self, imts, component=None, context=None, dtype=float): """Return an ``M x M`` cross-IMT correlation matrix.""" self._get_component(component) self.validate_imts(imts) matrix = numpy.zeros((len(imts), len(imts)), dtype) for row, from_imt in enumerate(imts): for col in range(row, len(imts)): correlation = self.rho( from_imt, imts[col], component, context) matrix[row, col] = correlation matrix[col, row] = correlation return matrix
[docs] def get_cross_correlation_mtx(self, imts): """Compatibility alias returning the historical float32 matrix.""" return self.correlation_matrix(imts, dtype=numpy.float32)
[docs] def covariance(self, sites, imts, component=None, context=None): """Embed cross-IMT matrices for each site in IMT-major ordering.""" num_sites = len(sites) num_imts = len(imts) imt_correlation = self.correlation_matrix( imts, component, context) covariance = numpy.zeros( (num_imts * num_sites, num_imts * num_sites)) for site_index in range(num_sites): indexes = [imt_index * num_sites + site_index for imt_index in range(num_imts)] covariance[numpy.ix_(indexes, indexes)] = imt_correlation return covariance
[docs]class TruncatedCrossIMTCorrelationModel(CrossIMTCorrelationModel): """Cross-IMT model able to sample truncated normal residuals.""" matrix_dtype = float def __init__(self, truncation_level=99.): if truncation_level < 1E-9: truncation_level = 1E-9 self.truncation_level = truncation_level self.distribution = stats.truncnorm( -truncation_level, truncation_level) self.cache = {} def _get_correlation_matrix(self, imts): key = tuple(imts) try: return self.cache[key] except KeyError: matrix = self.correlation_matrix( imts, dtype=self.matrix_dtype) self.cache[key] = matrix return matrix
[docs] def get_inter_eps(self, imts, num_events, rng): """Return an ``M x E`` matrix of correlated event terms.""" matrix = self._get_correlation_matrix(imts) return self._get_inter_eps_trunc_mvn(matrix, num_events, rng)
def _get_inter_eps_trunc_mvn(self, matrix, num_events, rng): num_imts = len(matrix) mean = numpy.zeros(num_imts) bounds = numpy.full(num_imts, self.truncation_level) seed = int(rng.integers(0, numpy.iinfo(numpy.int32).max)) correlation = numpy.array(matrix, copy=True) min_eigenvalue = numpy.linalg.eigvalsh(correlation).min() if (not numpy.isfinite(min_eigenvalue) or min_eigenvalue < 1E-8): correlation = corr_clipped(correlation, threshold=1E-8) try: samples = TruncatedMVN( mean, correlation, -bounds, bounds, seed=seed ).sample(num_events) if numpy.isfinite(samples).all(): return samples except RuntimeError as exc: if 'not positive semi-definite' not in str(exc): raise correlation = corr_clipped(correlation, threshold=1E-6) return TruncatedMVN( mean, correlation, -bounds, bounds, seed=seed ).sample(num_events)