dima / datasets.py
sparsetrace's picture
Upload 4 files
bf80929 verified
Raw
History Blame Contribute Delete
8.95 kB
# datasets.py
from __future__ import annotations
import os
import sys
import time
import urllib.request
from dataclasses import dataclass
from typing import Iterable, Optional, Sequence, Tuple, Union
import numpy as np
try:
import pandas as pd
except Exception as e: # pragma: no cover
raise ImportError("datasets.py requires pandas. Install with: pip install pandas") from e
# -----------------------------
# Config
# -----------------------------
ELECTRICITY_URL = "https://data.openei.org/files/8562/historic_load_hourly_2016_2023_county.h5"
ELECTRICITY_H5 = "historic_load_hourly_2016_2023_county.h5"
# -----------------------------
# Helpers
# -----------------------------
def download_with_progress(url: str, out_path: str, chunk_size: int = 1024 * 1024) -> None:
"""
Download a large file with a simple text progress bar (no extra deps).
Skips if file already exists.
"""
if os.path.exists(out_path):
return
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
print(f"[datasets] Downloading:\n {url}\n -> {out_path}")
with urllib.request.urlopen(url) as response, open(out_path, "wb") as out_file:
total = getattr(response, "length", None)
downloaded = 0
start_time = time.time()
while True:
chunk = response.read(chunk_size)
if not chunk:
break
out_file.write(chunk)
downloaded += len(chunk)
if total:
frac = downloaded / total
pct = frac * 100
bar_len = 30
filled = int(bar_len * frac)
bar = "#" * filled + "-" * (bar_len - filled)
rate = downloaded / (time.time() - start_time + 1e-9)
sys.stdout.write(
f"\r[{bar}] {pct:6.2f}% "
f"{downloaded / 1e6:8.1f} / {total / 1e6:8.1f} MB "
f"{rate / 1e6:4.1f} MB/s"
)
sys.stdout.flush()
if total:
print("\n[datasets] Download complete.")
else:
print("[datasets] Download complete.")
def _safe_cache_name(
*,
start: Optional[str],
stop: Optional[str],
resample: Optional[str],
agg: str,
n_features: Optional[int],
feature_method: str,
seed: int,
downcast_float32: bool,
) -> str:
def clean(x: Optional[str]) -> str:
if x is None:
return "none"
return str(x).replace(":", "-").replace("/", "-").replace(" ", "_")
return (
"electricity"
f"_start-{clean(start)}"
f"_stop-{clean(stop)}"
f"_res-{clean(resample)}"
f"_agg-{agg}"
f"_nf-{n_features if n_features is not None else 'all'}"
f"_fsel-{feature_method}"
f"_seed-{int(seed)}"
f"_f32-{int(bool(downcast_float32))}"
".npz"
)
def _require_tables_hint(err: Exception) -> None:
msg = (
"\n[datasets] Failed to read .h5. This usually means the optional dependency `tables` is missing.\n"
"Install once to build a cache:\n"
" pip install tables\n"
"or, if using extras:\n"
" pip install .[electricity]\n"
)
raise ImportError(msg) from err
def _coerce_datetime_index(df: pd.DataFrame) -> pd.DataFrame:
if not isinstance(df.index, pd.DatetimeIndex):
df.index = pd.to_datetime(df.index, utc=True)
if df.index.tz is None:
df.index = df.index.tz_localize("UTC")
return df.sort_index()
def _fill_missing(df: pd.DataFrame) -> pd.DataFrame:
# Conservative: forward-fill then back-fill; if still NaN, fill 0.
df = df.ffill().bfill()
if df.isna().values.any():
df = df.fillna(0.0)
return df
def _select_features(
df: pd.DataFrame,
*,
features: Optional[Sequence[str]] = None,
n_features: Optional[int] = None,
method: str = "variance",
seed: int = 0,
) -> pd.DataFrame:
"""
Feature selection on columns.
- features: explicit list of column labels
- n_features: choose subset
method: "variance" (top variance) or "random"
"""
if features is not None:
missing = [c for c in features if c not in df.columns]
if missing:
raise KeyError(f"Requested features not found in columns: {missing[:10]}{'...' if len(missing)>10 else ''}")
return df.loc[:, list(features)]
if n_features is None:
return df
n_features = int(n_features)
if n_features <= 0:
raise ValueError("n_features must be positive")
if n_features >= df.shape[1]:
return df
method = method.lower()
if method == "random":
rng = np.random.default_rng(int(seed))
cols = list(df.columns)
idx = rng.choice(len(cols), size=n_features, replace=False)
sel = [cols[i] for i in idx]
return df.loc[:, sel]
if method == "variance":
# top variance across time (after missing fill)
v = df.var(axis=0, ddof=0)
sel = list(v.sort_values(ascending=False).index[:n_features])
return df.loc[:, sel]
raise ValueError("feature_method must be one of: {'variance','random'}")
# -----------------------------
# Main API: Electricity
# -----------------------------
@dataclass(frozen=True)
class ElectricityData:
R_tX: np.ndarray
t_index: np.ndarray # int64 UTC ns
X_index: np.ndarray # dtype=object strings
def load_electricity(
*,
data_dir: str = "data",
url: str = ELECTRICITY_URL,
h5_name: str = ELECTRICITY_H5,
# slicing
start: Optional[str] = None, # e.g. "2019-01-01"
stop: Optional[str] = None, # e.g. "2020-01-01"
# resampling
resample: Optional[str] = None, # e.g. "D" for daily, "W" weekly; None keeps hourly
agg: str = "sum", # "sum" or "mean"
# feature downselect
features: Optional[Sequence[str]] = None,
n_features: Optional[int] = None,
feature_method: str = "variance", # "variance" or "random"
seed: int = 0,
# dtype / missing
downcast_float32: bool = True,
# caching
cache: bool = True,
cache_dir: Optional[str] = None,
force_rebuild_cache: bool = False,
) -> ElectricityData:
"""
Returns a manageable (T, X) array from the OpenEI historic county load dataset.
Behavior:
- If a matching .npz cache exists -> loads it (NO `tables` required).
- Else downloads .h5 and reads via pandas (requires `tables` installed once),
then applies slicing/resampling/feature-selection, and optionally writes cache.
Notes:
- index is returned as int64 UTC nanoseconds (portable, no pandas dependency later).
- columns are returned as strings (county IDs like 'p36041').
"""
data_dir = str(data_dir)
os.makedirs(data_dir, exist_ok=True)
if cache_dir is None:
cache_dir = os.path.join(data_dir, "cache")
os.makedirs(cache_dir, exist_ok=True)
agg = agg.lower()
if agg not in ("sum", "mean"):
raise ValueError("agg must be 'sum' or 'mean'")
cache_name = _safe_cache_name(
start=start, stop=stop, resample=resample, agg=agg,
n_features=n_features, feature_method=feature_method,
seed=seed, downcast_float32=downcast_float32,
)
cache_path = os.path.join(cache_dir, cache_name)
# 1) Load cache if present
if cache and (not force_rebuild_cache) and os.path.exists(cache_path):
z = np.load(cache_path, allow_pickle=True)
R_tX = z["R_tX"]
t_index = z["t_index"]
X_index = z["X_index"]
return ElectricityData(R_tX=R_tX, t_index=t_index, X_index=X_index)
# 2) Ensure H5 present
h5_path = os.path.join(data_dir, h5_name)
download_with_progress(url, h5_path)
# 3) Read H5 (requires tables)
try:
df = pd.read_hdf(h5_path)
except Exception as e:
_require_tables_hint(e)
# 4) Basic preprocess
df = _coerce_datetime_index(df)
df = _fill_missing(df)
# 5) Time slice
if start is not None or stop is not None:
df = df.loc[start:stop]
# 6) Resample
if resample is not None:
if agg == "sum":
df = df.resample(resample).sum()
else:
df = df.resample(resample).mean()
# 7) Feature selection
df = _select_features(df, features=features, n_features=n_features, method=feature_method, seed=seed)
# 8) Downcast
if downcast_float32:
df = df.astype("float32", copy=False)
# 9) Build outputs
R_tX = df.to_numpy()
t_index = df.index.view("int64") # UTC ns
X_index = np.asarray(df.columns.astype(str), dtype=object)
# 10) Cache
if cache:
np.savez_compressed(cache_path, R_tX=R_tX, t_index=t_index, X_index=X_index)
print(f"[datasets] Wrote cache: {cache_path}")
return ElectricityData(R_tX=R_tX, t_index=t_index, X_index=X_index)