Advanced topics¶
Classes and covariates, large rasters, time and memory, coordinate systems, seeds, sensitivity checks, sub-annual data, and how Zeit compares with the R package remotePARTS.
The snippets on this page run on the quick start's simulated cube (make_cube from the quick start), here with a trend of 0.001 per year (cube = make_cube(slope=0.001, seed=3)). The outputs shown are from those runs. landcover is a layer of three vertical bands (classes 1, 2, 3) with a 10 × 10 hole of NaN. elevation rises from 0 to 500 from west to east.
Fitting the per-pixel trends is often the slow part. Fit them once and pass them with trend= to every parts call.
Classes¶
classes= takes a (y, x) layer on the cube's grid: integers, floats or strings. Each class gets its own mean trend, tested against 0, and the chi-square test says whether the classes' trends differ:
PARTS: 6300 pixels, 6 partitions of 1000
spatial correlation: {'function': 'exponential', 'range': 233.61755655314306}
nugget (mean of the partitions): 0.1945
estimate se t p
coefficient
class 1.0 0.000937162 0.000124039 7.55537 4.8037e-14
class 2.0 0.000903458 0.00011821 7.64285 2.4595e-14
class 3.0 0.000815076 0.000126075 6.46499 1.09311e-10
chi-square test against a single mean: F = 0.6217, df = (2, 998), p = 0.7148
All three classes trend up (the cube has the same +0.001 everywhere), and they do not differ (p = 0.71), which is the right answer for this cube.
- Pixels without a class (NaN,
None) are left out of the test, like pixels without a trend. -
Row names are
class <value>:class 1.0for a float layer,class 1for an integer layer. A layer of names gives readable rows: -
Comparing two classes. The chi-square test compares all the classes at once. To test one pair, keep only those two:
two = landcover.where(landcover.isin([1, 2])) zeit.parts(cube, trend=trend, classes=two, partsize=1000).p_chisqr # 0.458Several pairwise tests are several tests: correct for multiple comparisons (for example, Bonferroni) or plan the comparisons before looking at the results.
-
Small classes. A class contributes to each partition in proportion to its area. A class with only a few pixels in each partition gets an unstable GLS estimate. Janus leaves out the classes with fewer than 10 pixels per partition on average (
n_class < 10 × n / partsize). That is a sensible rule here too: merge or drop such classes. - Do classes differ even when none trends? This is common on real data, because the regional signal cancels in the comparison (worked example).
Covariates¶
covariates= takes a dict of (y, x) layers. The design is then an intercept plus one column per covariate, and the chi-square test asks whether the covariates, together, explain the trends:
PARTS: 6400 pixels, 6 partitions of 1000
spatial correlation: {'function': 'exponential', 'range': 233.71829816142463}
nugget (mean of the partitions): 0.2019
estimate se t p
coefficient
intercept 0.00112549 0.000181167 6.21248 5.566e-10
elevation -9.9908e-07 5.87339e-07 -1.70103 0.0889893
chi-square test against a single mean: F = 2.836, df = (1, 999), p = 0.08649
- The
elevationcoefficient is the change in trend per unit of the covariate (per metre here). Theinterceptis the trend where the covariate is 0. Centre the covariate (subtract its mean) if you want the intercept to be the trend of an average pixel. - Pixels where a covariate is NaN are left out.
classes=andcovariates=can be combined: one mean per class plus the covariates' slopes. Avoid covariates that nearly repeat the classes. In this cube, the classes are bands from west to east and elevation also grows from west to east, and the run reportedrank-deficient covariance, replaced as remotePARTS doesnext to its chi-square p-value: a sign that the design is nearly collinear.- A covariate tests a linear effect. In the worked example, zones by distance to the sea differed (p = 0.028) while distance as a covariate had no linear effect (p = 0.46). Try both forms when the shape of the effect is unknown.
A region of interest¶
mask= restricts the test to a region, for example a protected area, a basin, or the land without water:
roi = elevation < 250 # any (y, x) boolean layer on the cube's grid
zeit.parts(cube, trend=trend, mask=roi, partsize=1000)
To use a polygon, rasterise it on the cube's grid first (rasterio.features.geometry_mask, or regionmask). Combined with classes=, the mask keeps only the classes' pixels inside the region.
Large rasters¶
zeit.parts loads the cube into memory, fits every pixel's trend, then uses npart × partsize pixels. On a large image:
- Aggregate first. A trend over decades rarely needs 30 m pixels to be tested regionally. The worked example reads a whole Landsat scene (55 million pixels) at 300 m with
resampling="average", about half a million pixels. It reads in under a minute and tests in about 10 seconds per question. - Or subsample.
npartpartitions ofpartsizepixels only ever usenpart × partsizepixels (150,000 with 100 partitions of 1500). Testing a regular subsample (cube[:, ::k, ::k]) gives nearly the same answer for less work. On the real window, the full grid gave p = 0.367 (range 23.0 km), one pixel in 2 × 2 gave 0.361 (23.5 km), and one in 3 × 3 gave 0.352 (23.3 km). Janus reads at most 500,000 pixels, one ink × kbeyond that. - Fit the trends lazily.
zeit.ar_trend(path, chunks="auto")computes block by block for cubes larger than memory, and the per-pixel maps can then be saved withzeit.save_raster. The regional test then runs on a coarser or sampled cube withtrend=from the same grid.
Time and memory¶
All the timings below are from the real window of the worked example (482,234 pixels) on a 20-thread CPU.
- Time grows linearly with
npartand as the cube ofpartsize: each partition needs a Cholesky factorisation of apartsize × partsizematrix. - Memory: each thread holds two
partsize × partsizematrices of 8-byte floats: 36 MB per thread at 1500 and 144 MB at 3000. With 20 threads atpartsize=3000, that is close to 3 GB, on top of the cube. Lowern_jobsif memory is short.
How many partitions?¶
Few. The SE combines the partitions' errors with the correlation between them (step 4). When that correlation is high, as on most real maps, more partitions do not reduce the SE: 4 partitions gave 0.00179 and 256 gave 0.00171. A few dozen partitions are enough to estimate the mean and the cross-partition correlation stably. 100 (Janus's cap) is a comfortable default, and npart=None (all the pixels) mostly costs time.
Partition size¶
On a map whose correlation the model describes well, the partition size does not change the answer (the simulated map in the figure: SE within 2% from 300 to 2000 pixels). On the real window, it did: p = 0.13, 0.27, 0.36, 0.43 and 0.54 at 500, 1000, 1500, 2000 and 3000 pixels. A correlation that stays high at every distance (a regional signal) is poorly described by an exponential, and larger partitions, which span longer distances, see more of it. Take a size dependence like this as a warning about the model, not as a choice to tune:
- keep the default 1500 (remotePARTS's authors use 1500 to 3000) rather than picking the size that gives the p you like;
- report the range of p over a few sizes, as in the worked example;
Coordinate systems¶
-
Zeit needs a projected CRS. Distances are taken in the cube's units, and a cube in a geographic CRS is refused:
ValueError: zeit.parts measures distances in the cube's units: reproject a cube in geographic coordinates first (e.g. zeit.load_raster(..., crs='EPSG:...'))Use the UTM zone of the region, or an equal-area projection for a large region.
range(given or fitted) is then in metres. A cube without a CRS is taken as it is (distances in its coordinates' units, or in pixels without coordinates). -scaled=Truedivides each partition's distances by the largest of them, as remotePARTS's defaultdistm_scaleddoes. The range is then a fraction of that largest distance (0.07 on the example cube), not metres. Use it only to match an R analysis. - Janus accepts a geographic CRS: it projects the coordinates equirectangularly at the region's mean latitude and measures distances in km. Over a region of a few hundred km at low or middle latitudes, its distances are within a few percent of a proper projection's. Its ranges are therefore in km, and Zeit's in the CRS units.
Seeds and reproducibility¶
seed= (default 0) draws both the pixels sampled to fit the correlation and the partitions. The same seed and inputs give identical results, on any number of threads. Another seed gives another draw, and so a slightly different answer: on the real window, seeds 0 to 9 gave p from 0.35 to 0.37 and ranges from 22.5 to 23.7 km. A conclusion that changes with the seed is a borderline one. Report the seed you used, or the range of p over a few seeds.
Zeit's partitions are drawn by its own generator (xoshiro256**), not by R's, so the same seed does not give the same partitions as remotePARTS. Janus uses seed=1.
Sensitivity checks¶
The p-value of PARTS depends on the fitted correlation, so check how much it moves when that correlation changes. On the example cube:
fitted = res.correlation["range"] # 234 m on this cube
for r in (fitted, 2 * fitted, 4 * fitted):
print(round(r), zeit.parts(cube, trend=trend, range=r, partsize=1000).table.loc["mean", "p"])
A longer range means more correlated pixels, so less information, a larger SE and a larger p. Since the fitted range tends to be too short (Is it calibrated?), a result that survives a range 1.5 to 2 times the fitted one is a robust one. Other checks:
| Check | Call |
|---|---|
| Another shape of correlation | correlation="exponential_power" (fits range and shape; res.correlation shows them) |
| A known range (another study, a variogram) | range=... in the cube's units |
| A fixed nugget | nugget=0.2 (a number instead of "ml") |
| Another partition size | partsize=1000, 3000 |
| Other draws | seed=1, 2, ... |
| The correlation plot | below |
The correlation plot¶
The most useful single diagnostic is the one in step 2: the correlation of the pixels' residual series against distance, with the fitted curve. This function draws it from the public API (for time="step" and no harmonics; the residuals are the series minus the fitted line):
import matplotlib.pyplot as plt
def correlation_check(cube, trend, res, n=600, seed=0):
"""Mean correlation of the pixels' residual series against distance, with the fitted curve."""
t = np.arange(1, cube.sizes["time"] + 1) # time="step"
fitted = trend.intercept.values + trend.slope.values * t[:, None, None]
resid = (cube.values - fitted).reshape(cube.sizes["time"], -1)
xs, ys = np.meshgrid(cube.x.values, cube.y.values)
ok = np.flatnonzero(np.isfinite(resid).all(axis=0))
pick = np.random.default_rng(seed).choice(ok, size=min(n, ok.size), replace=False)
r = np.corrcoef(resid[:, pick].T)
x, y = xs.ravel()[pick], ys.ravel()[pick]
d = np.hypot(x[:, None] - x, y[:, None] - y)
iu = np.triu_indices(pick.size, 1)
d, r = d[iu], r[iu]
edges = np.linspace(0, d.max(), 30)
which = np.digitize(d, edges)
mid = [d[which == i].mean() for i in range(1, edges.size) if (which == i).sum() > 20]
mean = [r[which == i].mean() for i in range(1, edges.size) if (which == i).sum() > 20]
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(mid, mean, "o", label="mean correlation of the pairs")
dd = np.linspace(0, d.max(), 200)
ax.plot(dd, np.exp(-dd / res.correlation["range"]), label="fitted exp(-d / range)")
ax.axhline(0, color="grey", lw=0.8)
ax.set_xlabel("distance (CRS units)")
ax.set_ylabel("correlation of the residuals")
ax.legend()
return fig
correlation_check(cube, trend, res).savefig("correlation_check.png")
What to look for:
- Dots that fall to 0 along the curve, as in the simulated panel of step 2: the model fits, and PARTS is close to calibrated.
- Dots below the curve near 0, then on it: a nugget. Expected, and handled in step 3.
- Dots that stay high at every distance (the real panel): a shared regional signal. Expect the answer to depend on the partition size, and run the sensitivity checks.
- Dots that stay flat near 0, then drop quickly: a smooth, Gaussian-like correlation, which the exponential describes badly. In a simulated check of this kind, 4 of 10 maps with no trend gave p < 0.01. Try
correlation="exponential_power", and be cautious with the result.
Sub-annual data¶
With several observations a year, add harmonics for the seasons and give the slope per year:
mt = zeit.ar_trend(monthly, time="year", harmonics=2) # slope per year; cos1, sin1, cos2, sin2
res = zeit.parts(monthly, trend=mt, time="year", harmonics=2, partsize=500) # a small test cube
On a simulated monthly cube (10 years, a trend of 0.003 per year and a seasonal cycle), the mean slope was 0.0031 per year and PARTS found 0.0032 ± 0.0003.
- Pass the same
time,harmonicsandperiodtopartsas toar_trend.partsuses them to compute the residual series for step 2, and with different harmonics the residuals would keep the seasons, which inflates the correlation. - The AR(1) model describes month-to-month persistence. Strong seasonality of the noise (more variable in the wet season) is not modelled. For long series, annual composites (a yearly mean, median or maximum) are often the more robust input.
Missing observations¶
gaps="exact"(the default): a missing year counts, so two observations two years apart are correlated by \(\rho^2\).gaps="ignore": the valid observations are taken as consecutive, as remotePARTS does. With few gaps, the two give nearly the same answer (on the example cube with 30% of the values removed at random: 0.000875 against 0.000869).-
min_valid:ar_trendleaves a pixel NaN when it has fewer valid observations (at least the design's columns + 2).partshas nomin_validof its own, so fit the trends with it and pass them:Pixels without a trend (NaN) are left out of the test. - No data values:
nodata="auto"reads the files' nodata value. When the files have none and use a fill value (0 over the sea, as in the worked example), mask it (cube.where(cube != 0)) or givenodata=0.
Parity with remotePARTS¶
Zeit implements the methods of the R package remotePARTS (Ives et al. 2021) in Rust, and its test suite compares it with results of remotePARTS 1.0.4 (rust/zeit-core/tests/remoteparts.rs). The GLS, each partition's fit and nugget, and the combined coefficients match to 1e-8. The AR(1) fits match fitAR to about 1e-6. The correlation's range matches fitCor to 1e-5, and the chi-square p-value matches to 1e-6 (the accuracy of the Imhof integration).
| remotePARTS (R) | Zeit (Python) |
|---|---|
fitAR, fitAR_map |
zeit.ar_trend |
fitCor |
inside zeit.parts (range=None, fit_n, correlation) |
covar_exp, covar_exppow |
correlation="exponential", "exponential_power" |
distm_scaled |
scaled=True (False, distances in the CRS units, by default) |
sample_partitions |
inside zeit.parts (partsize, npart, seed) |
fitGLS_partition |
zeit.parts |
nugget = NA (ML) |
nugget="ml" |
ncross |
ncross |
part_ttest, part_chisqr results |
res.table, res.p_chisqr |
The known differences:
- The random draws. Zeit draws the partitions and the correlation sample with its own generator, so its partitions differ from R's even for the same seed. Expect differences of the size of another seed (above).
- The nugget of crossed partitions. remotePARTS 1.0.4 computes the correlation between partitions after the first without their nugget. Zeit uses each partition's own nugget. With
nugget=fixed, the two agree. - Defaults. Zeit's
scaled=False(remotePARTS: scaled distances) andgaps="exact"(remotePARTS: gaps ignored). For a like-for-like comparison, usescaled=True, gaps="ignore", time="step". - The p floor. As in remotePARTS, the chi-square p-value is reported as at least 1e-6.