| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| """Utils for diffusion samplers. Makes use of dinosaur.spherical_harmonic.""" |
|
|
| import dataclasses |
| import functools |
| from typing import Any, cast, Optional, Tuple |
|
|
| import chex |
| from dinosaur import spherical_harmonic |
| from . import xarray_jax |
| from . import xarray_tree |
| import haiku as hk |
| import jax |
| import jax.numpy as jnp |
| import numpy as np |
| import xarray |
|
|
| |
| |
| |
| |
| EARTH_RADIUS_KM = 6371. |
| |
| |
| |
| EARTH_CIRCUMFERENCE_KM = EARTH_RADIUS_KM * 2 * np.pi |
|
|
|
|
| @dataclasses.dataclass(frozen=True) |
| class _ArrayGrid: |
| """A class that performs operations and transformations in the spectral basis. |
| |
| Attributes: |
| longitude_wavenumbers: num of longitude wavenumbers in the spectral basis. |
| total_wavenumbers: number of total wavenumbers in the spectral basis. |
| longitude_nodes: number of quadrature nodes along the lon direction. |
| latitude_nodes: number of quadrature nodes along the lat direction. |
| latitude_spacing: either 'gauss' or 'equiangular'. This determines the |
| spacing of nodal grid points in the latitudinal (north-south) direction. |
| """ |
| longitude_wavenumbers: int |
| total_wavenumbers: int |
| longitude_nodes: int |
| latitude_nodes: int |
| latitude_spacing: str |
|
|
| @classmethod |
| def with_lat_lon( |
| cls, |
| lat: np.ndarray, |
| lon: np.ndarray, |
| ) -> '_ArrayGrid': |
| """_ArrayGrid for use with data in specified lat/lon grid (in degrees).""" |
|
|
| latitude_nodes = lat.shape[0] |
| longitude_nodes = lon.shape[0] |
| latitude_spacing = _infer_latitude_spacing(lat) |
| if latitude_spacing in ['equiangular', 'gauss']: |
| if longitude_nodes != 2 * latitude_nodes: |
| |
| |
| raise ValueError( |
| 'Unexpected number of longitude nodes. ' |
| f'Expected {2 * latitude_nodes}, got {longitude_nodes}') |
| elif latitude_spacing == 'equiangular_with_poles': |
| if longitude_nodes != 2 * (latitude_nodes - 1): |
| |
| |
| raise ValueError( |
| 'Unexpected number of longitude nodes. ' |
| f'Expected {2 * (latitude_nodes - 1)}, got {longitude_nodes}') |
| else: |
| raise ValueError(f'Unexpected latitude_spacing={latitude_spacing}') |
| max_wavenumber = int(longitude_nodes // 2) - 1 |
| grid = cls( |
| longitude_wavenumbers=max_wavenumber+1, |
| |
| |
| total_wavenumbers=max_wavenumber+1, |
| longitude_nodes=longitude_nodes, |
| latitude_nodes=latitude_nodes, |
| latitude_spacing=latitude_spacing, |
| ) |
| _verify_nodal_axes(lat, lon, grid.nodal_axes) |
| return grid |
|
|
| @functools.cached_property |
| def _grid(self) -> spherical_harmonic.Grid: |
| return spherical_harmonic.Grid( |
| spherical_harmonics_impl=spherical_harmonic.RealSphericalHarmonics, |
| **dataclasses.asdict(self), |
| ) |
|
|
| @functools.cached_property |
| def nodal_axes(self) -> Tuple[np.ndarray, np.ndarray]: |
| """Longitude and sin(latitude) coordinates of the nodal basis.""" |
| return self._grid.nodal_axes |
|
|
| @functools.cached_property |
| def modal_axes(self) -> Tuple[np.ndarray, np.ndarray]: |
| """Longitudinal and total wavenumbers (m, l) of the modal basis.""" |
| return self._grid.modal_axes |
|
|
| def to_nodal(self, x: chex.Array) -> chex.Array: |
| """Maps `x` from a modal to nodal representation.""" |
| return self._grid.to_nodal(x) |
|
|
|
|
| def _infer_latitude_spacing(lat: np.ndarray) -> str: |
| """Infers the type of latitude spacing given the latitude.""" |
| if not np.all(np.diff(lat) > 0.): |
| raise ValueError('Latitude values are expected to be sorted.') |
|
|
| if np.allclose(np.diff(lat), lat[1] - lat[0]): |
| if np.isclose(max(lat), 90.): |
| spacing = 'equiangular_with_poles' |
| else: |
| spacing = 'equiangular' |
| else: |
| spacing = 'gauss' |
| return spacing |
|
|
|
|
| def _verify_nodal_axes(lat_coords: np.ndarray, lon_coords: np.ndarray, |
| nodal_axes: Tuple[np.ndarray, np.ndarray]): |
| nodal_axes_lon, nodal_axes_sin_lat = nodal_axes |
| if not np.allclose(nodal_axes_sin_lat, np.sin(np.deg2rad(lat_coords))): |
| raise ValueError( |
| "Latitude coords don't match those used by " |
| "spherical_harmonic.SphericalHarmonicBasis.") |
| if not np.allclose(nodal_axes_lon, np.deg2rad(lon_coords)): |
| raise ValueError( |
| "Longitude coords don't match those used by " |
| "spherical_harmonic.SphericalHarmonicBasis.") |
|
|
|
|
| class Grid: |
| """xarray wrapper around _ArrayGrid.""" |
|
|
| @classmethod |
| def for_nodal_data( |
| cls, |
| nodal_data: xarray.DataArray, |
| ) -> 'Grid': |
| """A Grid for use with a given shape of nodal (lat/lon grid) data. |
| |
| This uses the maximum number of spherical harmonics that the grid is able |
| to resolve. |
| |
| This class supports data arrays with latitude spacings as defined by |
| "dinosaur.spherical_harmonic". In summary: |
| * 'equiangular': equally spaced (by `d_lat`) values between -90 + d_lat and |
| 90 - d_lat / 2. In our case, longitude must also be spaced by `d_lat`. |
| * 'equiangular_with_poles': equally spaced (by `d_lat`) values between -90 |
| and 90. In our case, longitude must also be spaced by `d_lat`. |
| * 'gauss': Gauss-Legendre nodes. |
| |
| Args: |
| nodal_data: An xarray with 'lat' and 'lon' dimensions and coordinates in |
| degrees. |
| |
| Returns: |
| A grid with the specified latitude_nodes, with |
| longitude_nodes=2*latitude_nodes and max_wavenumber=latitude_nodes-1. |
| """ |
|
|
| grid = _ArrayGrid.with_lat_lon( |
| nodal_data.coords['lat'].data, |
| nodal_data.coords['lon'].data) |
| return cls(grid, |
| nodal_data.coords['lat'].data, |
| nodal_data.coords['lon'].data) |
|
|
| def __init__(self, |
| grid: _ArrayGrid, |
| lat_coords: np.ndarray, |
| lon_coords: np.ndarray): |
| _verify_nodal_axes(lat_coords, lon_coords, grid.nodal_axes) |
| self._underlying = grid |
| |
| |
| |
| self._lat_coords = lat_coords |
| self._lon_coords = lon_coords |
| self._longitude_wavenumber_coords, self._total_wavenumber_coords = ( |
| grid.modal_axes) |
|
|
| @property |
| def total_wavenumber_coords(self) -> xarray.DataArray: |
| """Coords that must be used for 'total_wavenumber' dimension.""" |
| return xarray.DataArray( |
| data=self._total_wavenumber_coords, |
| dims=('total_wavenumber',), |
| coords={'total_wavenumber': self._total_wavenumber_coords}) |
|
|
| @property |
| def longitude_wavenumber_coords(self) -> xarray.DataArray: |
| """Coords that must be used for 'longitude_wavenumber' dimension.""" |
| return xarray.DataArray( |
| data=self._longitude_wavenumber_coords, |
| dims=('longitude_wavenumber',), |
| coords={'longitude_wavenumber': self._longitude_wavenumber_coords}) |
|
|
| def to_nodal( |
| self, modal_data: xarray.DataArray) -> xarray.DataArray: |
| """Applies the inverse spherical harmonic transform. |
| |
| Args: |
| modal_data: A tree of xarray.DataArray with 'longitude_wavenumber' and |
| 'total_wavenumber' dimensions with coords |
| `self.longitude_wavenumber_coords` and `self.total_wavenumber_coords` |
| respectively, and with the same sparsity pattern described under |
| `to_modal`. |
| |
| Returns: |
| Corresponding tree where the 'longitude_wavenumber' and |
| 'total_wavenumber' dimensions are replaced by 'lat', 'lon' dimensions. |
| """ |
| def inverse_transform(modal: xarray.DataArray) -> xarray.DataArray: |
| if (not np.all(modal.coords['longitude_wavenumber'] == |
| self._longitude_wavenumber_coords) or |
| not np.all(modal.coords['total_wavenumber'] == |
| self._total_wavenumber_coords)): |
| raise ValueError('Wavenumber coords don\'t follow required convention.') |
|
|
| return xarray_jax.apply_ufunc( |
| self._underlying.to_nodal, modal, |
| input_core_dims=[['longitude_wavenumber', 'total_wavenumber']], |
| output_core_dims=[['lon', 'lat']], |
| ).assign_coords( |
| lon=self._lon_coords, |
| lat=self._lat_coords, |
| ) |
|
|
| return xarray_tree.map_structure(inverse_transform, modal_data) |
|
|
|
|
| def sample( |
| key: jnp.ndarray, |
| power_spectrum: xarray.DataArray, |
| template: xarray.DataArray, |
| grid: Optional[Grid] = None, |
| ) -> xarray.DataArray: |
| """Samples Gaussian Process noise on a sphere, with a given power spectrum. |
| |
| This means the noise will have the given power spectrum *in expectation*; the |
| power spectrum of individual samples may vary. |
| |
| The noise will be isotropic, meaning the distribution is invariant to |
| rotations of the sphere. |
| |
| The marginal variance of the returned values will be equal to the total power, |
| i.e. the sum of power_spectrum. So if you want unit marginal variance, just |
| make sure to normalize the power_spectrum to sum to 1. |
| |
| Args: |
| key: JAX rng key. |
| power_spectrum: An array with shape (total_wavenumber,) giving the power |
| which is desired at each total wavenumber (corresponding to a wavelength |
| EARTH_CIRCUMFERENCE/total_wavenumber) for total wavenumbers 0 up to some |
| maximum. This is in squared units of the quantity being sampled. |
| template: An array with the shape that you want the samples in, containing |
| 'lat' and 'lon' dimensions. If other dimensions are present, we draw |
| multiple independent samples along these other dimensions. |
| grid: spherical_harmonic.Grid on which to sample the noise. If not specified |
| a grid will be created based on `template`, however note you may save some |
| RAM and compute by re-using a single Grid instance across multiple calls. |
| |
| Returns: |
| DataArray with the same shape as template. |
| """ |
| if grid is None: |
| grid = Grid.for_nodal_data(template) |
| dims = [d for d in template.dims if d not in ('lat', 'lon')] |
| shape = [template.sizes[d] for d in dims] |
| coords = {name: coord for name, coord in template.coords.items() |
| if name not in ('lat', 'lon')} |
| dims.extend(('total_wavenumber', 'longitude_wavenumber')) |
| shape.extend((len(grid.total_wavenumber_coords), |
| len(grid.longitude_wavenumber_coords))) |
| coords.update({'total_wavenumber': grid.total_wavenumber_coords, |
| 'longitude_wavenumber': grid.longitude_wavenumber_coords}) |
| coeffs = xarray_jax.DataArray( |
| data=jax.random.normal(key, shape), dims=dims, coords=coords) |
| |
| |
| mask = ( |
| abs(coeffs.longitude_wavenumber) <= coeffs.total_wavenumber |
| ).astype(np.float32) |
| |
| |
| |
| |
| multiplier = mask * np.sqrt(power_spectrum / mask.sum( |
| 'longitude_wavenumber', skipna=False)) |
| |
| |
| multiplier *= np.sqrt(4 * np.pi) |
| |
| |
| coeffs *= multiplier |
| result = cast(xarray.DataArray, grid.to_nodal(coeffs)) |
| result = result.astype(template.dtype) |
| return result.transpose(*template.dims) |
|
|
|
|
| def spherical_white_noise_like(template: xarray.Dataset) -> xarray.Dataset: |
| """Samples isotropic mean 0 variance 1 white noise on the sphere.""" |
| def spherical_white_noise_like_dataarray(data_array: xarray.DataArray |
| ) -> xarray.DataArray: |
| num_wavenumbers = data_array.lon.shape[0] // 2 |
| key = hk.next_rng_key() |
| return sample( |
| key=key, |
| power_spectrum=xarray_jax.DataArray( |
| data=np.array([1/num_wavenumbers for _ in range(num_wavenumbers)]), |
| dims=['total_wavenumber']), |
| template=data_array) |
| return template.map(spherical_white_noise_like_dataarray) |
|
|
|
|
| def rho_inverse_cdf( |
| min_value: float, |
| max_value: float, |
| rho: float, |
| cdf: Any) -> Any: |
| """Quantiles of rho distribution used for noise levels at sampling time. |
| |
| This is parameterised by rho as in Eqn 5 from the Elucidating paper |
| (but with max/min flipped so that quantiles are given in ascending not |
| descending order). It's equivalent to a Beta[rho, 1] distribution rescaled to |
| [min_value, max_value]. |
| |
| At sampling time we use noise levels at fixed quantiles of this distribution. |
| Unlike in the paper, we also use the same distribution for noise levels at |
| training time (albeit potentially with different parameters, and sampling from |
| it at random). |
| |
| Args: |
| min_value: |
| max_value: |
| Define the support of the distribution. |
| rho: |
| Shape parameter. |
| cdf: |
| Value or values between 0 and 1 indicating which quantile you want. Can |
| be a numpy or jax array. |
| |
| Returns: |
| Quantiles of the distribution, with same shape/type as `cdf`. |
| """ |
| return ( |
| min_value**(1 / rho) + cdf * |
| (max_value**(1 / rho) - min_value**(1 / rho)) |
| )**rho |
|
|
|
|
| def tree_where( |
| cond: jnp.ndarray, |
| xs: Any, |
| ys: Any |
| ) -> Any: |
| """Like jnp.where but works with trees for xs and ys (but not for cond).""" |
| return jax.tree_util.tree_map(lambda x, y: jnp.where(cond, x, y), xs, ys) |
|
|
|
|
| def noise_schedule( |
| max_noise_level: float = 80., |
| min_noise_level: float = 0.002, |
| num_noise_levels: int = 30, |
| rho: float = 7., |
| ) -> np.ndarray: |
| """Computes a descending noise schedule for sampling, ending with zero.""" |
| noise_levels = rho_inverse_cdf( |
| min_value=min_noise_level, |
| max_value=max_noise_level, |
| rho=rho, |
| |
| |
| cdf=np.linspace(1, 0, num_noise_levels)) |
| |
| |
| |
| return np.append(noise_levels, 0.) |
|
|
|
|
| def stochastic_churn_rate_schedule( |
| noise_levels: np.ndarray, |
| stochastic_churn_rate: float = 0., |
| churn_min_noise_level: float = 0.05, |
| churn_max_noise_level: float = 50.0, |
| ) -> np.ndarray: |
| """Computes a stochastic churn rate for each noise level.""" |
| num_noise_levels = len(noise_levels)-1 |
| |
| |
| |
| per_step_churn_rate = min(stochastic_churn_rate / num_noise_levels, |
| np.sqrt(2) - 1) |
| return ( |
| (churn_min_noise_level <= noise_levels[:-1]) & |
| (noise_levels[:-1] <= churn_max_noise_level) |
| ) * per_step_churn_rate |
|
|
|
|
| def apply_stochastic_churn( |
| x: Any, |
| noise_level: jax.typing.ArrayLike, |
| stochastic_churn_rate: jax.typing.ArrayLike, |
| noise_level_inflation_factor: jax.typing.ArrayLike, |
| ) -> tuple[Any, jax.typing.ArrayLike]: |
| """Returns x at higher noise level, and the higher noise level itself.""" |
| |
| new_noise_level = noise_level * (1.0 + stochastic_churn_rate) |
| noise_diff = new_noise_level**2 - noise_level**2 |
| |
| |
| |
| |
| noise_diff = jnp.maximum(noise_diff, 0) |
| extra_noise_stddev = jnp.sqrt(noise_diff)* noise_level_inflation_factor |
| updated_x = x + spherical_white_noise_like(x) * extra_noise_stddev |
| return updated_x, new_noise_level |
|
|
|
|