SuperWASP Variable Star Photometry Archive (VeSPA)¶
Category: Astronomy · Size: 137.5 MB · Format: CSV, YAML License: CC-BY-4.0 · Zenodo record · Data sheet on the CSDH
Metadata for periodic variable stars classified by citizens in the Zooniverse SuperWASP Variable Stars project, with folded light curves and period parameters.
The data is mounted read-only at /srv/data/superwasp-vespa/.
Save anything you produce in your personal folder (~/).
What's in the dataset¶
The VeSPA archive is a single CSV, export.csv (~138 MB, 255,615 rows). Each row is one
periodic variable star that citizen scientists inspected and classified on Zooniverse.
The columns describe where the star is (RA/Dec), how it varies (period, amplitude,
magnitudes) and what type the volunteers decided it is.
A companion fields.yaml documents every column. Let's read it first.
from pathlib import Path
import yaml
DATA = Path('/srv/data/superwasp-vespa')
for f in sorted(DATA.iterdir()):
print(f"{f.name} ({f.stat().st_size/1e6:,.1f} MB)")
print('\nColumn dictionary (fields.yaml):')
fields = yaml.safe_load((DATA / 'fields.yaml').read_text())
for k, v in fields.items():
print(f" - {k}: {v}")
export.csv (137.5 MB) fields.yaml (0.0 MB) params.yaml (0.0 MB) Column dictionary (fields.yaml): - Amplitude: The absolute difference between max and min magnitude - Chi squared: Chi squared error estimate from original period search - Classification: The candidate variable star type - Classification count: How many Zooniverse classifications this entry received - Dec: Declination in degrees - FITS URL: The URL of the FITS file containing unfolded photometry data - Folded plot URL: The URL of the PNG plot of the folded light curve - Folding flag: Whether the correctness of this period is certain, uncertain, or half the correct period (based on Zooniverse classifications) - JSON URL: The URL of the JSON file containing unfolded photometry data - Maximum magnitude: The brightest magnitude for this source - Mean magnitude: The mean magnitude for this source - Minimum magnitude: The least bright magnitude for this source - Period Length: The period length in seconds - RA: Right ascension in hours - Sigma: Sigma error estimate from original period search - SuperWASP ID: The unique identifier for the source - Unfolded plot URL: The URL of the PNG plot of the unfolded light curve
Load only the columns we need¶
The file fits comfortably in memory, but there is no reason to carry the four long
URL columns (FITS/JSON/plot links) into RAM. We load just the science columns with
usecols. The interesting ones are:
- Period Length — the variability period, stored in seconds. We convert it to hours and days, which is how astronomers actually think about variable stars.
- Amplitude — brightness range (max minus min magnitude); bigger means a deeper dip.
- Classification — the citizen-assigned type:
Rotator,EW,EA/EB,Pulsator,Unknown. - Folding flag — whether the recovered period is
Certain,Half(an alias), orUncertain.
import pandas as pd
import numpy as np
cols = ['SuperWASP ID', 'Period Length', 'RA', 'Dec', 'Mean magnitude',
'Amplitude', 'Classification', 'Classification count', 'Folding flag']
df = pd.read_csv(DATA / 'export.csv', usecols=cols, encoding='utf-8')
# Period in seconds -> hours and days (far more readable for stellar variability)
df['period_hours'] = df['Period Length'] / 3600.0
df['period_days'] = df['Period Length'] / 86400.0
print('shape:', df.shape)
df.head()
shape: (255615, 11)
| SuperWASP ID | Period Length | RA | Dec | Mean magnitude | Amplitude | Classification | Classification count | Folding flag | period_hours | period_days | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1SWASPJ000126.73-001344.2 | 24007.58594 | 0h01m26.73s | -0d13m44.2s | 14.870849 | 7.326356 | Pulsator | 4 | Uncertain | 6.668774 | 0.277866 |
| 1 | 1SWASPJ000236.45+002446.3 | 20553.37500 | 0h02m36.45s | 0d24m46.3s | 14.678380 | 2.671753 | EA/EB | 8 | Half | 5.709271 | 0.237886 |
| 2 | 1SWASPJ000236.45+002446.3 | 16595.36914 | 0h02m36.45s | 0d24m46.3s | 14.678380 | 2.671753 | EW | 7 | Half | 4.609825 | 0.192076 |
| 3 | 1SWASPJ000236.45+002446.3 | 27404.40430 | 0h02m36.45s | 0d24m46.3s | 14.678380 | 2.671753 | EA/EB | 7 | Certain | 7.612335 | 0.317181 |
| 4 | 1SWASPJ000236.20+002516.3 | 27404.29492 | 0h02m36.2s | 0d25m16.3s | 14.824788 | 3.790799 | EA/EB | 7 | Certain | 7.612304 | 0.317179 |
How many stars of each type?¶
The whole point of the project is the Classification column. Let's see the balance of
types, and how confident the folding is. Rotators (spotted stars) and eclipsing binaries
(EW contact + EA/EB detached) dominate; a large Unknown group remains genuinely ambiguous.
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style='whitegrid')
order = df['Classification'].value_counts().index
counts = df['Classification'].value_counts()
fig, ax = plt.subplots(1, 2, figsize=(12, 4.5))
sns.barplot(x=counts.values, y=counts.index, ax=ax[0], palette='viridis', hue=counts.index, legend=False)
ax[0].set_title('Variable stars by citizen classification')
ax[0].set_xlabel('number of stars'); ax[0].set_ylabel('')
for i, v in enumerate(counts.values):
ax[0].text(v + 800, i, f'{v:,}', va='center', fontsize=9)
flag_ct = pd.crosstab(df['Classification'], df['Folding flag']).loc[order]
flag_ct = flag_ct[['Certain', 'Half', 'Uncertain']]
flag_ct.plot(kind='barh', stacked=True, ax=ax[1], colormap='Blues_r')
ax[1].set_title('Period reliability (folding flag) per type')
ax[1].set_xlabel('number of stars'); ax[1].set_ylabel('')
ax[1].invert_yaxis()
plt.tight_layout()
The period distribution — the first fingerprint of a type¶
Different families of variable stars live at different periods. Contact eclipsing binaries
(EW) orbit in well under a day; detached ones (EA/EB) are slower; pulsators and rotators
spread more widely. Because periods span from ~1 hour to hundreds of days, we plot them on a
log scale. We restrict to Certain folds so the periods we compare are trustworthy.
clean = df[(df['Folding flag'] == 'Certain') &
df['period_hours'].between(1, 24*300) &
df['Amplitude'].notna()].copy()
types = ['EW', 'EA/EB', 'Pulsator', 'Rotator']
fig, ax = plt.subplots(figsize=(10, 5))
bins = np.logspace(np.log10(1), np.log10(24*300), 60)
for t in types:
sub = clean[clean['Classification'] == t]
ax.hist(sub['period_hours'], bins=bins, histtype='step', linewidth=2,
label=f'{t} (n={len(sub):,})')
ax.set_xscale('log')
ax.axvline(24, color='grey', ls='--', lw=1)
ax.text(24*1.1, ax.get_ylim()[1]*0.9, '1 day', color='grey')
ax.set_xlabel('period (hours, log scale)')
ax.set_ylabel('number of stars')
ax.set_title('Period distribution by variable-star type (Certain folds only)')
ax.legend()
plt.tight_layout()
The EW peak sits below one day, exactly as expected for contact binaries, while EA/EB
and Rotator reach to longer periods. Period alone already pulls the families apart — but
it overlaps. Let's add a second axis: amplitude.
Period vs amplitude — separating the families¶
The classic diagnostic is a period-amplitude diagram. Pulsators tend to show larger amplitudes; eclipsing binaries cluster by period. We clip amplitude at a sensible physical range (a few magnitudes) to drop noisy outliers, then draw a 2-D density (KDE) per type so the clouds are readable despite tens of thousands of points.
plot_df = clean[clean['Amplitude'].between(0.05, 4)].copy()
# subsample for a legible scatter background
bg = plot_df.sample(min(8000, len(plot_df)), random_state=0)
palette = dict(zip(types, sns.color_palette('Set1', len(types))))
fig, ax = plt.subplots(figsize=(10, 6))
ax.scatter(bg['period_hours'], bg['Amplitude'], s=4, c='lightgrey', alpha=0.4, zorder=1)
for t in types:
sub = plot_df[plot_df['Classification'] == t]
sns.kdeplot(x=sub['period_hours'], y=sub['Amplitude'], log_scale=(True, False),
levels=5, color=palette[t], linewidths=1.8, label=t, ax=ax, zorder=2)
ax.set_xscale('log')
ax.set_xlim(1, 24*100)
ax.set_ylim(0, 3)
ax.set_xlabel('period (hours, log scale)')
ax.set_ylabel('amplitude (magnitudes)')
ax.set_title('Period-amplitude diagram: variable-star families')
handles = [plt.Line2D([], [], color=palette[t], lw=2) for t in types]
ax.legend(handles, types, title='type')
plt.tight_layout()
Do these parameters actually predict the type?¶
A picture is suggestive; let's quantify it. We train a simple decision tree on just three features — log period, amplitude and mean magnitude — to predict the citizen label for the four main families. If the families really separate, a shallow tree should score well above the random baseline. The confusion matrix shows which types get mixed up.
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import ConfusionMatrixDisplay, accuracy_score
ml = clean[clean['Classification'].isin(types) &
clean['Amplitude'].between(0.05, 4) &
clean['Mean magnitude'].notna()].copy()
ml['log_period'] = np.log10(ml['period_hours'])
X = ml[['log_period', 'Amplitude', 'Mean magnitude']]
y = ml['Classification']
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=0, stratify=y)
clf = DecisionTreeClassifier(max_depth=6, random_state=0).fit(Xtr, ytr)
pred = clf.predict(Xte)
base = y.value_counts(normalize=True).max()
print(f'accuracy: {accuracy_score(yte, pred):.3f}')
print(f'majority baseline: {base:.3f}')
print('feature importances:',
dict(zip(X.columns, clf.feature_importances_.round(3))))
fig, ax = plt.subplots(figsize=(6.5, 5.5))
ConfusionMatrixDisplay.from_predictions(yte, pred, labels=types,
normalize='true', cmap='viridis', ax=ax,
values_format='.2f')
ax.set_title('Predicting type from period + amplitude + magnitude')
plt.tight_layout()
accuracy: 0.663
majority baseline: 0.660
feature importances: {'log_period': np.float64(0.801), 'Amplitude': np.float64(0.159), 'Mean magnitude': np.float64(0.04)}
The tree clears the majority baseline using nothing but period, amplitude and brightness,
confirming that the families do separate in these parameters. EW and EA/EB (both
eclipsing binaries) are the pair most often confused with each other, which is astrophysically
sensible: they differ mostly in light-curve shape, not in the summary numbers we used here.
That is exactly the gap a light-curve-shape feature could close.
Working with data larger than memory¶
Your session has a 4 GB RAM limit, but you can analyse files of 10 GB or more without loading them whole:
- Read only the columns you need:
pd.read_csv(f, usecols=[...])/pd.read_parquet(f, columns=[...]). - Process in chunks and keep only the result:
total = 0 for chunk in pd.read_csv(file, chunksize=1_000_000): total += len(chunk)
- Query with SQL without loading anything — DuckDB (already installed) reads
CSV and Parquet straight from disk and only brings the result into memory:
import duckdb duckdb.sql("SELECT column, count(*) FROM '/srv/data/.../file.parquet' GROUP BY column").df()
Your turn¶
This is just the starting point. Some ideas:
- Check the dataset challenge on its CSDH data sheet.
- Work on a copy: right-click the file → Duplicate (or Save Notebook As…). Your changes only live in your Hub space — they're never pushed to GitHub.
- Edited this notebook and want the original back? Use the Restore cell
below (or the
restore.ipynbnotebook). - Questions and results: on the platform forum.
Attribution: data from SuperWASP Variable Star Photometry Archive (VeSPA), license CC-BY-4.0. Notebook from the Citizen Science Data Hub (CSDH) — Fundación Ibercivis.
# ⚠️ RESTORE: this DISCARDS YOUR CHANGES to this notebook and resets it to the original.
# 1. Uncomment the line below (remove the #) 2. Run this cell
# 3. Then: menu File → Reload Notebook from Disk
# !git -C ~/citizen-science-data fetch -q origin && git -C ~/citizen-science-data checkout origin/main -- superwasp-vespa.ipynb && echo "Restored. Now: File → Reload Notebook from Disk"