Cephalopod Songs
Skin
A floating image depicts the structure of cephalopod skin in cross-section, slowly turning. The work was inspired by Peter Godfrey-Smith’s account in Other Minds: The Octopus, the Sea, and the Deep Origins of Consciousness, of how cephalopods change the colour of their skin.
At the surface are the chromatophores: sacs of yellow, red, and brown pigment that open and close. Beneath them are the iridophores: thin, tilted plates that reflect a narrow band of colour, shifting as they turn and flashing silver when seen face-on. At the base are the leucophores: white cells that reflect whatever light reaches them. As light passes down through the pigment sacs, it reflects upward from the layers beneath and travels back up through them. The Merge slider closes the three layers into a single surface, where the colours are produced through their interaction.
The light
The patterns of light are drawn from actual records. In Reef, the data comes from light measured by a sensor 1.9 metres below the surface at Davies Reef, in the central Great Barrier Reef, with readings taken every ten minutes. In Arctic, it comes from a radiometer 9 metres below the surface in Cambridge Bay, Nunavut, with readings taken every minute. Both use the first week of September 2024. At the default pace, a day passes in four minutes. The Pace slider ranges from one day in ten minutes to one day in one minute.
Sleep
The octopus sleeps according to the rhythm described by Medeiros and colleagues (2021): periods of quiet sleep, when it is pale and still, interrupted every thirty to forty minutes by an active sleep lasting less than a minute, during which the skin moves through changing colours. Quiet sleep passes at ten times its natural speed and active sleep at twice its natural speed, so that the changes are visible.
Transformer echo
Beneath the three visible layers lies a fourth, fainter one: an echo. A small transformer was trained for each work on readings from its own sensor during July and August 2024, at ten-minute intervals. Neither was trained on the September week used in the work. As the work runs, the model receives the previous six hours of readings and predicts the next one.
The echo repeats the structure of the skin. It is faint and grey when the model’s prediction is accurate, and brighter and coloured when it is wrong. It is amber when the model predicted more light than was measured, and blue when the measured light exceeded its prediction.
The model has learned the daily cycle, so at dawn and dusk the two skins tend to move together, with the echo following only slightly behind the measured light. It cannot anticipate an individual cloud, so under changing cloud cover they separate: the skin flickers while the echo changes colour in response to the model’s errors. The model runs locally in the page.
Sound
Changes in light generate the notes: a note sounds only when the light moves by a set amount. Its pitch corresponds to the level of light. Dawn therefore rises, dusk falls, and a steady noon remains silent.
Each note is answered by the model, an octave beneath the note it predicted. It is in tune when its prediction is accurate, and out of tune when the observed light differs from what was expected.
During active sleep, a low chord rocks between two harmonies like a lullaby, voiced according to the colours displayed by the chromatophores. Night introduces a hollow fifth. Beneath this is a layer of filtered noise resembling the sound of water, which rises and falls with the level of darkness.
The key is B minor.
Sources
Light, Reef: Australian Institute of Marine Science (AIMS). (2009). Northern Australia Automated Marine Weather and Oceanographic Stations. Davies Reef Base Station, photosynthetically active radiation at 1.9 m, 1 to 7 September 2024. doi:10.25845/5c09bf93f315d, accessed 4 October 2026. Licensed under CC BY 3.0 AU.
Light, Arctic: Ocean Networks Canada Society. (2024). Cambridge Bay Radiometer Deployed 2024-08-29. Photosynthetically active radiation at 9 m, 1 to 7 September 2024. doi:10.34943/3c374d6d-a870-48e1-9889-93344357bb05, accessed 5 October 2026. Licensed under CC BY 4.0.
Sleep: Medeiros, S.L.S. et al. (2021). Cyclic alternation of quiet and active sleep states in the octopus. iScience 24(4), 102223. doi:10.1016/j.isci.2021.102223
Skin: Godfrey-Smith, P. (2016). Other Minds: The Octopus, the Sea, and the Deep Origins of Consciousness. New York: Farrar, Straus and Giroux.
Model: two transformers of the same design (36 readings of context, 24 dimensions, 2 heads, 10,537 weights each), one trained on each station’s July and August 2024 readings, running in the pages. Designed by the artist; built and trained by Claude (Anthropic). The model’s code is open source under the MIT Licence.
© 2026 Alexandra Emberley. All rights reserved, except the model’s code (MIT Licence) and the light records (CC BY 3.0 AU, AIMS; CC BY 4.0, Ocean Networks Canada).
The model
The small transformer in this work, written in Python with numpy and open source under the MIT Licence. Anyone may read, run, change, and share it. One copy was trained on each light record; the learned numbers each holds after training are inside that work’s page. The light records belong to AIMS and Ocean Networks Canada and carry their own licences.
# Cephalopod Songs
# The model: a small transformer, written from scratch in Python with numpy.
# Designed by the artist; built and trained by Claude (Anthropic).
# One copy was trained on each light record; the learned numbers are inside each work's page.
"""A small transformer, in numpy, with its gradients written by hand.
Given the last K readings of light (and the time of day), it predicts the next reading.
Sibling of the Cordyceps Septet model; the same shape, a different question."""
import numpy as np
K, D, H, FF, L, FIN = 36, 24, 2, 48, 2, 3 # context (6 h of 10-min readings), width, heads, feed-forward, layers, features per step
DH = D // H
def init(seed=1):
r = np.random.RandomState(seed); s = lambda *sh: (r.randn(*sh) * 0.08).astype(np.float64)
P = {"Win": s(FIN, D), "bin": np.zeros(D), "pos": s(K, D), "Wout": s(D, 1), "bout": np.zeros(1)}
for l in range(L):
P.update({f"Wq{l}": s(D, D), f"Wk{l}": s(D, D), f"Wv{l}": s(D, D), f"Wo{l}": s(D, D),
f"g1{l}": np.ones(D), f"b1{l}": np.zeros(D), f"W1{l}": s(D, FF), f"c1{l}": np.zeros(FF),
f"W2{l}": s(FF, D), f"c2{l}": np.zeros(D), f"g2{l}": np.ones(D), f"b2{l}": np.zeros(D)})
return P
def ln_f(x, g, b, eps=1e-5):
mu = x.mean(-1, keepdims=True); xc = x - mu; var = (xc * xc).mean(-1, keepdims=True); inv = 1 / np.sqrt(var + eps)
xh = xc * inv; return xh * g + b, (xh, inv, g)
def ln_b(dy, cache):
xh, inv, g = cache; dg = (dy * xh).sum((0, 1)); db = dy.sum((0, 1)); dxh = dy * g
dx = inv * (dxh - dxh.mean(-1, keepdims=True) - xh * (dxh * xh).mean(-1, keepdims=True)); return dx, dg, db
def softmax(z): z = z - z.max(-1, keepdims=True); e = np.exp(z); return e / e.sum(-1, keepdims=True)
def forward(P, X, want_attn=False):
"""X: (B, K, FIN). Returns prediction (B,) and a cache for backward."""
B = X.shape[0]; h = X @ P["Win"] + P["bin"] + P["pos"]; cache = {"X": X, "hs": []}; attns = []
mask = np.triu(np.ones((K, K)), 1).astype(bool)
for l in range(L):
a, c1 = ln_f(h, P[f"g1{l}"], P[f"b1{l}"])
q = (a @ P[f"Wq{l}"]).reshape(B, K, H, DH).transpose(0, 2, 1, 3); k = (a @ P[f"Wk{l}"]).reshape(B, K, H, DH).transpose(0, 2, 1, 3); v = (a @ P[f"Wv{l}"]).reshape(B, K, H, DH).transpose(0, 2, 1, 3)
sc = q @ k.transpose(0, 1, 3, 2) / np.sqrt(DH); sc = np.where(mask, -1e9, sc); at = softmax(sc)
o = (at @ v).transpose(0, 2, 1, 3).reshape(B, K, D); ao = o @ P[f"Wo{l}"]; h2 = h + ao
f, c2 = ln_f(h2, P[f"g2{l}"], P[f"b2{l}"]); u = f @ P[f"W1{l}"] + P[f"c1{l}"]; r = np.maximum(u, 0); fo = r @ P[f"W2{l}"] + P[f"c2{l}"]; h3 = h2 + fo
cache["hs"].append((h, a, c1, q, k, v, at, o, h2, f, c2, u, r)); attns.append(at); h = h3
last = h[:, -1, :]; y = (last @ P["Wout"] + P["bout"])[:, 0]; cache["last"] = last; cache["h"] = h
return (y, cache, attns) if want_attn else (y, cache)
def backward(P, cache, dy):
"""dy: (B,) gradient of the loss with respect to the prediction. Returns gradients for every parameter."""
G = {k: np.zeros_like(v) for k, v in P.items()}; B = dy.shape[0]
G["Wout"] = cache["last"].T @ dy[:, None]; G["bout"] = dy.sum(keepdims=True)
dh = np.zeros_like(cache["h"]); dh[:, -1, :] = dy[:, None] * P["Wout"][:, 0]
mask = np.triu(np.ones((K, K)), 1).astype(bool)
for l in reversed(range(L)):
h, a, c1, q, k, v, at, o, h2, f, c2, u, r = cache["hs"][l]
# feed-forward
dfo = dh; G[f"W2{l}"] += np.einsum("bkf,bkd->fd", r, dfo); G[f"c2{l}"] += dfo.sum((0, 1)); dr = dfo @ P[f"W2{l}"].T; du = dr * (u > 0)
G[f"W1{l}"] += np.einsum("bkd,bkf->df", f, du); G[f"c1{l}"] += du.sum((0, 1)); df = du @ P[f"W1{l}"].T
dh2, dg2, db2 = ln_b(df, c2); G[f"g2{l}"] += dg2; G[f"b2{l}"] += db2; dh2 = dh2 + dh
# attention
dao = dh2; G[f"Wo{l}"] += np.einsum("bkd,bke->de", o, dao); do = (dao @ P[f"Wo{l}"].T).reshape(B, K, H, DH).transpose(0, 2, 1, 3)
dat = do @ v.transpose(0, 1, 3, 2); dv = at.transpose(0, 1, 3, 2) @ do
dsc = at * (dat - (dat * at).sum(-1, keepdims=True)); dsc = np.where(mask, 0, dsc) / np.sqrt(DH)
dq = dsc @ k; dk = dsc.transpose(0, 1, 3, 2) @ q
dq = dq.transpose(0, 2, 1, 3).reshape(B, K, D); dk = dk.transpose(0, 2, 1, 3).reshape(B, K, D); dv = dv.transpose(0, 2, 1, 3).reshape(B, K, D)
G[f"Wq{l}"] += np.einsum("bkd,bke->de", a, dq); G[f"Wk{l}"] += np.einsum("bkd,bke->de", a, dk); G[f"Wv{l}"] += np.einsum("bkd,bke->de", a, dv)
da = dq @ P[f"Wq{l}"].T + dk @ P[f"Wk{l}"].T + dv @ P[f"Wv{l}"].T
dh1, dg1, db1 = ln_b(da, c1); G[f"g1{l}"] += dg1; G[f"b1{l}"] += db1; dh = dh1 + dh2
G["pos"] += dh.sum(0); G["bin"] += dh.sum((0, 1)); G["Win"] += np.einsum("bkf,bkd->fd", cache["X"], dh)
return G
def features(lum, t0_index):
"""lum: a 1-D array of light levels (0..1); returns per-step features [lum, sin(hour), cos(hour)] with 144 steps a day,
where t0_index is the index of the step at local midnight."""
n = len(lum); ph = ((np.arange(n) - t0_index) % 144) / 144 * 2 * np.pi
return np.stack([lum, np.sin(ph), np.cos(ph)], 1)
def windows(F, lum):
"""Every window of K steps and the reading that follows it."""
n = len(lum) - K; X = np.stack([F[i:i + K] for i in range(n)]); y = lum[K:K + n]; return X, y
# MIT Licence
#
# Copyright (c) 2026 Alexandra Emberley
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
# associated documentation files (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge, publish, distribute,
# sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all copies or
# substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
# NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.