Before this: Building data cubes from image collections

From series to embeddings

How does the time series of a place become a few numbers that say what it is?

One window of the Brazilian Cerrado, with one image a month. Follow one place through the months, and see what the satellite recorded there.

Step 01 of 5

What does the satellite give us for one place?

The book asks sits to turn the Landsat images of the Cerrado, from BDC, into one image a month, cut to the limits of the biome.

The book's code

cerrado_cube_reg_2018 <- sits_regularize(
    cube  = cerrado_cube_local,
    period = "P1M",
    roi = cerrado_limits,
    multicores = 8,
    output_dir = tempdir_r_data,
    progress = TRUE
)

Where the sits book writes this call: emb_example.qmd, lines 234–241

A window of the Brazilian Cerrado, 256 × 256 pixels of 30 m, 7.68 km across, as Landsat saw it in May 2017.

The colours are false: SWIR16, NIR08 and RED as red, green and blue. Infrared says more about plants and soil than visible light, so growing vegetation comes out green and bare soil pink.

The satellite comes back, and the cube keeps one image a month: 24 months, from January 2017 to December 2018.

The book’s cube is regular: one image a month, every month on the same grid. Building data cubes from image collections shows how sits makes one. Press Play, or drag along the strip.

Now read one pixel, the one marked on the image, through the 24 months. The sensor measures 6 bands, BLUE, GREEN, RED, NIR08, SWIR16 and SWIR22, so the pixel has 6 lines, one per band.

That set of lines is a time series: all the satellite knows about these 30 m of ground. How sits reads the series of a place: How does one place become a time series?

45% of the pixel-months of this window were not observed: 8 of the 24 months have no observation at all, and only 8 are complete: they fall from May to August, the empty ones from September to March.

The strip shows how much was observed each month, and the image fades the rest. sits fills the gaps with impute_linear(): a hollow dot is a filled value. Now point at the image, or click it.

The cube these images come from

The images are a 256 × 256 pixel crop of tile 009010 of the regular cube e-sensing/cerrado_cube_reg_2018 on Hugging Face: 12 tiles, 6 bands, 24 months. It is the cube the encoder of this page read.

The sits book makes it from the BDC collection LANDSAT-OLI-16D: it copies the tiles of the Cerrado with sits_cube_copy(), makes them regular with one image a month with the call below, and reads the cube back with sits_from_hf().

cerrado_cube_reg_2018 <- sits_regularize(
    cube  = cerrado_cube_local,
    period = "P1M",
    roi = cerrado_limits,
    multicores = 8,
    output_dir = tempdir_r_data,
    progress = TRUE
)

Where the sits book writes this call: emb_example.qmd, lines 234–241

Series are kept for every second pixel in each direction (128 × 128 of the 256 × 256 pixels); a pixel you pick shows the series of the kept pixel at or just before it.

One stretch for every month: in each colour, the reflectance at the 2nd percentile of all months is drawn black and the one at the 98th white.

ColourBandBlackWhite
redSWIR160.09680.4926
greenNIR080.18180.4379
blueRED0.01840.1879

This figure draws the window month by month from rgb-months.png, series.bin and series-mask.bin, with JavaScript.

May 2017

The window month by month: the strip of months, one pixel's bands as lines, and the months nobody observed.

Try it: A pixel through time

Step 02 of 5

Why not classify the raw series?

The book asks sits to pass the series of every pixel of the Cerrado cube through the encoder trained with sits_ssl_lejepa(), which writes 64 values for each pixel.

The book's code

lejepa_embedding <- sits_encode(
    data = cerrado_cube_reg_2018,
    encoder = lejepa_encoder,
    memsize = 12,
    multicores = 6,
    gpu_memory = 12,
    batch_size = 4096,
    output_dir = tempdir_r_emb
)

Where the sits book writes this call: emb_example.qmd, lines 436–444

The pixel of step 1 as a table: one row per band, one column per month, 6 × 24 = 144 values for one place. The stronger a cell, the higher its value within its row.

Series compared as they come, value against value: Do all places behave the same?

Not all of them were measured. Round cells are months with no clear view, which sits filled with impute_linear(). Across the window, 45% of the pixel-months were filled.

A classifier would read them as if they were seen. And so many numbers for one place are a lot: the book notes that patterns in so many dimensions are costly to find, and unreliable without many training samples.

sits_encode() hands the 144 values to an encoder, a network that rewrites them. Step 3 tells how it learned to.

The funnel is a drawing: inside the encoder there is arithmetic on all the values at once, not movement.

64 values come back for the pixel, warm above zero and cool below. No cell of the row comes from one cell of the table.

Both ends are recorded: encoding the window again with the published encoder gave the published values back, identical in all 4,194,304 values.

Try another place on the small image, or on step 1’s image, which picks the same pixel at full size, and switch between the table and the row. Does the short list keep what matters? The details hold a recorded check.

What a classifier makes of these numbers: Classifying EO embeddings for land use and land cover applications

One pixel · 2017–2018

6 bands × 24 months = 144 values

Recorded runBDC LANDSAT-OLI-16D · tile 009010 · 24 months, 2017-01 to 2018-12144 values

BLUEGREENREDNIR08SWIR16SWIR22JFMAMJJASONDJFMAMJJASOND20172018

This figure draws one pixel from series.bin (2.4 MB), series-mask.bin (393 kB), embeddings.bin (4.2 MB) and rgb-months.png (1.4 MB), with JavaScript.

Each row of the table is shaded from its band's lowest value to its highest.

Before: the values of one pixel, each row shaded from its lowest value to its highest. Round cells are months without a clear observation, filled by sits. After: the values sits_encode() wrote for the same pixel, warm above zero and cool below, the colour growing with the square root of the size. The funnel between them is a diagram, not a computation. Both ends are recorded: encoding the series of this window again gave the published embeddings back.

A recorded check: nearest neighbours

Fewer numbers would be no gain if they forgot what a place is. One recorded check: take 1,532 places whose land cover is known, and for each of them let the 5 most similar others vote on its label.

Do nearest neighbours share a label?

1,532 labelled samples · 5 nearest neighbours vote · the sample itself left out

As raw series · 144 values · 82.3%
As embeddings · 64 values · 90.2%

+7.9 points, with 80 fewer numbers per place.

Recorded run · at most 150 samples of each of 11 classes · Euclidean distance · encoder sits_ssl_lejepa(), 647,967 series

So as embeddings a place's nearest neighbours vote for its own label more often. Not always: the vote still fails for 9.8%.

Try it: From a series to a vector

Step 03 of 5

How does an encoder learn with no labels?

The book asks sits to train an encoder on series of the Cerrado that carry no label, with the method sits_ssl_lejepa(), so that it writes 64 values for each series.

The book's code

lejepa_encoder <- sits_pre_train(
    samples = pretrain_samples,
    rl_method = sits_ssl_lejepa(
        embedding_dim = 64,
        epochs = 150,
        batch_size = 2048,
        encoder_model = sits_tempcnn(),
        verbose = TRUE
    )
)

Where the sits book writes this call: emb_example.qmd, lines 322–331

The encoder of this site was fitted on 518,374 time series (129,593 more held out to check it), and nobody told it what any of them shows. With no label to learn from, the method makes its own task out of the data.

Here is one of those series: band SWIR22 over its 24 dates. What it shows on the ground is not said here; the encoder was never told either.

While it trains, sits_ssl_lejepa() draws two views of it: it joins the values with straight lines, picks two sets of points along them that share none, and stretches each set back onto the 24 dates.

Each view is the same curve read on other days. Nothing new is observed. Replay draws them again.

Both views go through the same encoder: one network, the same weights. Each comes out as a vector of 64 numbers.

A small second network, the projector, follows, with 128 outputs. The loss reads the projector’s two outputs; the projector is dropped after training.

Training pulls the two outputs together, and through them the encoder’s vectors. The views differ in their days and agree in the shape of the seasons, so the encoder is pushed to keep what they share.

This part of the loss is called invariance. In sits_ssl_lejepa() it carries 0.98 of the weight.

There is a lazy way to win: give every series the same output. The views always agree, and the output says nothing. That is collapse.

So the loss has a second part, SIGReg: over the outputs of many series at once, it keeps the cloud they form round. It carries the other 0.02. No label appears anywhere in the method.

How sits draws two views

The series is one of the 647,967 the published encoder was pre-trained with (518,374 fitted, 129,593 held out for validation): 24 dates from 2017-01-01, in 6 bands (BLUE, GREEN, RED, NIR08, SWIR16 and SWIR22).

While it trains, sits_ssl_lejepa() draws the views by resampling the series: it interpolates the 24 dates onto 48 steps, draws two sets of 12 steps with no step in common, taking steps from each quarter of the period, and stretches each set back onto the 24 dates. It draws new views each time training reads a series: these two are one draw.

The figure draws band SWIR22, the one whose values span the widest range; the views were drawn on all 6 bands. This pair was drawn from the reflectance, for this page; in training sits draws them from the normalised values.

In the sits book: Time series augmentation for self-supervised learning. The book’s figure of this augmentation is from Saget et al. 2025.

Pre-training with sits_ssl_lejepa()

The method, drawn from one real series

Diagramof the method · the series is realof the method

A diagram, not a measurement. The series and its two views are real: sits drew the views from the series. The networks, their two outputs and the cloud of many series are drawn, not computed. The weights are the defaults of sits_ssl_lejepa().

Step 04 of 5

What did the training give?

A reduced run, made for this site: sits pre-trained an encoder on a random subset of 20,000 series of the Cerrado, for 30 epochs on a CPU, in 9 min.

The curve is the loss sits recorded after each epoch: from 1.952 after the first to 0.269 after the last. The dashed line is the loss on the 4,000 series it held out.

The dots are 1,532 labelled samples, places whose land cover someone wrote down, where the encoder put them after epochs 1, 2, 4, 8, 16 and 30: flattened onto two axes, coloured by label.

The encoder never saw these labels; they only colour the dots. Where a label comes from: Data structure for image time series. Press an epoch to see the cloud then.

Do neighbours share a label? Take the 5 samples nearest to a sample in the 64 numbers the encoder gives it, not in the flat picture, and see whether most of them carry its label.

That holds for 73.7% of the samples after epoch 1 and 85.6% after epoch 30. Compared as raw series, 82.3%: the reduced run passes it at the snapshot of epoch 16.

The encoder of this site learned from 647,967 series and stopped early, after 54 of 150 epochs (7.1 h on a GPU). It kept only its loss per epoch: a curve, no cloud. Its neighbours share the label for 90.2% of the samples.

Switch runs, drag along a curve, or press a label to bring it forward. Compare the shapes of the curves, not their levels.

Pre-training with sits_ssl_lejepa()

The loss of the reduced run made for this site

Recorded run20,000 series · 30 epochs · CPU · 9 min20,000 series · 30 epochs · CPU

Loss · log scaletrainvalidation

Recorded by sits: the loss after each epoch, on a log scale.

What differs between the two runs

The reduced run, as it was run for this site:

      sits_pre_train(samples, rl_method = sits_ssl_lejepa(epochs = 30, embedding_dim = 64, seed = 20261011))
    

Both runs are recorded by sits: the reduced one when it was made for this site, the published one as the encoder object stores it. The marked rows are the settings both runs recorded, where their values differ.

Reduced runPublished encoderDifferent
Series20,000647,967yes
Fitted on16,000518,374yes
Held out4,000129,593yes
Epochs30 epochs54 of 150 epochs, stopped earlyyes
Batch512 series512 seriesno
Embedding64 values64 valuesno
DeviceCPUGPUyes
Duration9 min7.1 hyes
Seed2026101110yes

Of the arguments of sits_ssl_lejepa() both runs recorded, 12 have the same value: embedding_dim, proj_dim, lambda, num_knots, num_slices, batch_size, validation_split, opt_hparams, lr_decay_epochs, lr_decay_rate, patience and min_delta. The book's call (the header of step 3) writes batch_size = 2048; the published encoder recorded batches of 512. Nothing here separates the effect of one difference from another.

How the snapshots were made

sits has no hook to save the encoder after each epoch, so each snapshot is a separate run made the same way, stopped at that epoch; the losses of those runs are identical to the full run's.

The 1,532 samples are the same at every epoch. The two axes are the first two principal components of the final epoch, the same for every epoch, so a cloud that looks larger is larger. Between two snapshots the figure interpolates the positions and says so; the statistic is never interpolated.

For each sample, its 5 nearest other samples (Euclidean distance, the sample itself left out) vote with their labels; on a tie the nearest neighbour among the tied labels decides. The distances are measured in all 64 dimensions of the embedding, not in the two of the picture; for the raw series, in their 144 reflectance values, which are not standardised.

EpochSame label among the nearest neighboursVariance on the two axes
173.7%31.8% + 17.5%
274.1%38.9% + 14.8%
478.8%29.0% + 19.1%
880.0%25.5% + 22.7%
1682.5%24.6% + 21.5%
3085.6%23.4% + 21.3%
ReferenceSame label among the nearest neighbours
Raw series82.3%
Published encoder90.2%

Try it: Pre-training, replayed

Step 05 of 5

What are these 64 numbers?

plot() draws an embeddings cube like any other cube: three of its bands as red, green and blue, for one tile.

The book's code

plot(lejepa_embedding, tile = "009010", 
     red = "EMB11", green = "EMB12", blue = "EMB13")

Where the sits book writes this call: emb_example.qmd, lines 445–446

For every pixel of the window, sits_encode() has turned its whole time series into 64 numbers. sits stores them as 64 bands, EMB01 to EMB64: an embeddings cube, again a stack of images.

A screen has three colours to give, so the picture shows three bands at a time: EMB11, EMB12 and EMB13 as red, green and blue, as the sits book plots them. The colours mean nothing by themselves.

Now one pixel, the one marked on the image. Its 64 numbers, in order, are the bars: that list is its embedding, a vector.

A bar above the line is a positive number, a bar below it a negative one. The scale is the same for every pixel.

Each number is also a map. Take one place in the list and look at its value in every pixel: a picture of the window in grey, dark where the number is low, bright where it is high.

The band arrows step through all 64 bands, one at a time, and pointing at a bar shows its band. No band has a name like “red” or “moisture”: nobody chose what each one should measure; the encoder settled on it during training.

Any three bands make a colour picture. Choose them with the R, G and B menus, or press Surprise me; Book’s three brings back the book’s. Point at the image to read any pixel, and click to keep it.

The colours change completely and the shapes do not: what tells one field from the next is in the vectors, not in a choice of colours.

Embeddings cube · tile 009010

EMB11, EMB12 and EMB13 as red, green and blue

Recorded runsits_encode() · encoder from sits_ssl_lejepa() · 256 × 256 px · 64 bands64 bands

This figure draws the embeddings from embeddings.bin (4.2 MB), with JavaScript.

Recorded by sits: the embeddings cube that sits_encode() wrote for the window.

Values beyond the scale

The bars of every pixel are drawn on one scale, so two pixels can be compared: the range that holds 99% of the 4,194,304 values of the window, rounded up to a multiple of 0.5. The bars are drawn from −5.5 to +5.5; 0.7% of the values of the window lie beyond that, and their bars end in a small point past the rule. Pointing at such a bar reads its true value.

Separately, storing the values clips some of them. This page keeps each value in one byte: per band, 255 steps between the 0.5% and 99.5% quantiles of that band over the window and the labelled samples together, and a value beyond them kept as the nearest end. Of the values of this window, 0.7% were clipped that way (0.95% over window and samples together). Stored and restored, the values of the samples are off by 0.184 on average (root mean square).

FactValue
Range of the bars−5.5 to +5.5
Values of the window beyond the bars0.7%
Values per pixel64
Values in the window4,194,304
Values of the window clipped when stored (a separate count)0.7%
Error after storing (root mean square)0.184

Try it: One embedding, band by band