dm–dt Tutorial¶
A dm-dt map is a 2D histogram of magnitude differences (Δm) versus log-time differences (lg Δt) for all pairs of observations in a light curve. It was introduced as an ML input representation by Mahabal et al. 2011.
DmDt produces a fixed-size 2D array that can be fed directly into a CNN.
# %pip install light-curve matplotlib
Setup¶
import numpy as np
import light_curve as licu
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
dmdt = licu.DmDt.from_borders(
min_lgdt=0, # lg Δt_min = 1 day
max_lgdt=2, # lg Δt_max = 100 days
max_abs_dm=1.5, # |Δm| up to 1.5 mag
lgdt_size=32,
dm_size=32,
norm=["dt"],
)
print(f"Map shape: {dmdt.shape}")
Map shape: (32, 32)
Computing a map¶
Call .points(t, m) to compute the dm-dt map for a single light curve:
rng = np.random.default_rng(0)
t = np.sort(rng.uniform(0, 100, 200)).astype(np.float64)
period = 7.0
m = 15.0 + 0.2 * np.sin(2 * np.pi * t / period) + rng.normal(0, 0.03, 200)
map_ = dmdt.points(t, m)
print(f"Map shape: {map_.shape} (lgdt_size × dm_size)")
fig, ax = plt.subplots(figsize=(5, 4))
im = ax.imshow(
map_.T,
origin="lower",
aspect="auto",
extent=[0, 2, -1.5, 1.5],
cmap="Blues",
norm=mcolors.PowerNorm(gamma=0.5, vmin=0),
)
ax.set_xlabel("lg Δt (days)")
ax.set_ylabel("Δm (mag)")
ax.set_title("dm-dt map — periodic variable (P = 7 d)")
fig.colorbar(im, ax=ax, label="normalised count")
plt.tight_layout()
plt.show()
Map shape: (32, 32) (lgdt_size × dm_size)
Comparing light curve types¶
Different variability classes leave distinct signatures in dm-dt space:
rng2 = np.random.default_rng(42)
t2 = np.sort(rng2.uniform(0, 100, 200)).astype(np.float64)
configs = [
("Constant star", 15.0 + rng2.normal(0, 0.05, 200)),
("Periodic (P = 7 d)", 15.0 + 0.4 * np.sin(2 * np.pi * t2 / 7) + rng2.normal(0, 0.03, 200)),
("Transient (Gaussian)", 15.0 - 1.0 * np.exp(-((t2 - 50) ** 2) / 50) + rng2.normal(0, 0.05, 200)),
]
fig, axes = plt.subplots(1, 3, figsize=(13, 4), sharey=True)
norm = mcolors.PowerNorm(gamma=0.5, vmin=0)
for ax, (title, m2) in zip(axes, configs):
mp = dmdt.points(t2, m2)
im = ax.imshow(
mp.T, origin="lower", aspect="auto",
extent=[0, 2, -1.5, 1.5], cmap="Blues", norm=norm,
)
ax.set_title(title)
ax.set_xlabel("lg Δt (days)")
fig.colorbar(im, ax=ax, label="normalised count", shrink=0.8)
axes[0].set_ylabel("Δm (mag)")
plt.tight_layout()
plt.show()
Error-weighted maps (gausses)¶
When photometric errors are known, use .gausses() to spread each observation pair
into a Gaussian kernel in Δm space. The third argument is the variance σ² (not σ).
Here we use strong noise (σ = 0.3 mag) to make the difference clearly visible:
!!! warning "Variance, not std dev" expects the variance σ² — remember to square your error array: .
!!! warning "Variance, not std dev"
.gausses() expects the variance σ² — remember to square your error array: err ** 2.
rng3 = np.random.default_rng(7)
t3 = np.sort(rng3.uniform(0, 100, 150)).astype(np.float64)
err_large = 0.3 # large photometric error to make the effect visible
m3 = 15.0 + 0.5 * np.sin(2 * np.pi * t3 / 7) + rng3.normal(0, err_large, 150)
err3 = np.full(150, err_large)
map_pts = dmdt.points(t3, m3)
map_gaus = dmdt.gausses(t3, m3, err3 ** 2) # err² = variance
map_diff = map_pts - map_gaus
fig, axes = plt.subplots(1, 3, figsize=(13, 4), sharey=True)
kw = dict(origin="lower", aspect="auto", extent=[0, 2, -1.5, 1.5],
cmap="Blues", norm=mcolors.PowerNorm(gamma=0.5, vmin=0))
im1 = axes[0].imshow(map_pts.T, **kw)
axes[0].set_title("points()")
fig.colorbar(im1, ax=axes[0], label="count", shrink=0.8)
im2 = axes[1].imshow(map_gaus.T, **kw)
axes[1].set_title("gausses() — error-weighted")
fig.colorbar(im2, ax=axes[1], label="count", shrink=0.8)
# Difference panel: use a diverging colormap
vmax = np.abs(map_diff).max()
im3 = axes[2].imshow(map_diff.T, origin="lower", aspect="auto",
extent=[0, 2, -1.5, 1.5],
cmap="RdBu_r", vmin=-vmax, vmax=vmax)
axes[2].set_title("points() − gausses()")
fig.colorbar(im3, ax=axes[2], label="difference", shrink=0.8)
for ax in axes:
ax.set_xlabel("lg Δt (days)")
axes[0].set_ylabel("Δm (mag)")
plt.tight_layout()
plt.show()
Batch processing¶
.points_many() computes maps for a list of light curves in one call,
returning a 3D array of shape (N, lgdt_size, dm_size):
rng4 = np.random.default_rng(99)
light_curves = []
for _ in range(100):
n_obs = int(rng4.integers(50, 200))
t_i = np.sort(rng4.uniform(0, 100, n_obs)).astype(np.float64)
m_i = rng4.normal(15, 0.3, n_obs)
light_curves.append((t_i, m_i))
maps = dmdt.points_many(light_curves)
print(f"Batch output shape: {maps.shape} # (N, lgdt_size, dm_size)")
print(f"Ready for CNNs or flatten to (N, {maps.shape[1] * maps.shape[2]}) for sklearn.")
Batch output shape: (100, 32, 32) # (N, lgdt_size, dm_size) Ready for CNNs or flatten to (N, 1024) for sklearn.
Normalisation¶
norm controls how the raw pair counts are scaled before returning the map.
Three modes are available (combinable):
norm value |
Effect |
|---|---|
[] (default) |
Raw pair counts |
["dt"] |
Each lg-Δt row divided by its total count — gives a conditional distribution p(Δm | Δt) per row |
["max"] |
Entire map divided by its maximum value — brings scale to [0, 1] |
["dt", "max"] |
"dt" first, then "max" applied to the result |
import numpy as np
import light_curve as licu
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
rng = np.random.default_rng(0)
t = np.sort(rng.uniform(0, 100, 200)).astype(np.float64)
m = 15.0 + 0.5 * np.sin(2 * np.pi * t / 7.0) + rng.normal(0, 0.03, 200)
norm_opts = [[], ["dt"], ["max"], ["dt", "max"]]
titles = ["no norm", "norm=[dt]", "norm=[max]", "norm=[dt,max]"]
fig, axes = plt.subplots(1, 4, figsize=(14, 4), sharey=True)
for ax, norm, title in zip(axes, norm_opts, titles):
d = licu.DmDt.from_borders(
min_lgdt=0, max_lgdt=2, max_abs_dm=1.5,
lgdt_size=32, dm_size=32, norm=norm,
)
mp = d.points(t, m)
per_norm = mcolors.PowerNorm(gamma=0.5, vmin=0, vmax=mp.max() or 1)
im = ax.imshow(
mp.T, origin="lower", aspect="auto",
extent=[0, 2, -1.5, 1.5], cmap="Blues", norm=per_norm,
)
fig.colorbar(im, ax=ax, shrink=0.8)
ax.set_xlabel("lg Δt (days)")
ax.set_title(title)
axes[0].set_ylabel("Δm (mag)")
plt.suptitle("Effect of dm–dt map normalisation")
plt.tight_layout()
plt.show()