Whittaker accepts data as a Python dictionary mapping column names to 1-D NumPy arrays. This is the simplest and most portable data format. It works with any data source and avoids coupling the library to a specific DataFrame backend.
The data dictionary
Every fit() and predict() call expects a dictionary where keys are column names (strings) and values are 1-D NumPy arrays of the same length.
import numpy as np
import whittaker as wk
# The data dictionary: one key per column, one array per key
data = {
"x1": np.linspace(0, 2 * np.pi, 100),
"x2": np.random.default_rng(23).uniform(0, 1, 100),
"y": np.sin(np.linspace(0, 2 * np.pi, 100))
+ np.random.default_rng(23).normal(0, 0.3, 100),
}
# Fit a model (the formula references keys from the dictionary)
model = wk.GAM("y ~ s(x1) + s(x2)")
model.fit(data, method="REML")
print(f"Fitted with {len(data['y'])} observations")
Fitted with 100 observations
Every column referenced in the formula must be present in the dictionary. Extra columns are ignored.
Converting from DataFrames
If your data is in a Polars or Pandas DataFrame, convert it to a dictionary before passing it to Whittaker.
From Polars
import polars as pl
# Create a Polars DataFrame
df_pl = pl.DataFrame(
{
"x": np.linspace(0, 2 * np.pi, 100),
"y": np.sin(np.linspace(0, 2 * np.pi, 100))
+ np.random.default_rng(23).normal(0, 0.3, 100),
}
)
# Convert to a dict of NumPy arrays
data_from_polars = {col: df_pl[col].to_numpy() for col in df_pl.columns}
# Fit the model
model = wk.GAM("y ~ s(x)")
model.fit(data_from_polars, method="REML")
print(f"Fitted from Polars: EDF = {model.edf_total:.1f}")
Fitted from Polars: EDF = 7.5
From Pandas
import pandas as pd
# Create a Pandas DataFrame
df_pd = pd.DataFrame(
{
"x": np.linspace(0, 2 * np.pi, 100),
"y": np.sin(np.linspace(0, 2 * np.pi, 100))
+ np.random.default_rng(23).normal(0, 0.3, 100),
}
)
# Convert to a dict of NumPy arrays
data_from_pandas = {col: df_pd[col].to_numpy() for col in df_pd.columns}
# Fit the model
model = wk.GAM("y ~ s(x)")
model.fit(data_from_pandas, method="REML")
print(f"Fitted from Pandas: EDF = {model.edf_total:.1f}")
Fitted from Pandas: EDF = 7.5
For Polars DataFrames backed by Arrow arrays, to_numpy() is often zero-copy, so no data is duplicated. For Pandas with NumPy-backed columns, to_numpy() returns a view of the underlying array. This means conversion is essentially free for typical numerical data.
Prediction data
The predict() method accepts the same dictionary format. You only need to include the covariate columns (the response column is not required for prediction).
# Predict on new data (only covariate columns are needed)
new_data = {"x": np.linspace(0, 2 * np.pi, 50)}
preds = model.predict(new_data)
print(f"Predictions shape: {preds.values.shape}")
import altair as alt
x_plot = np.linspace(0, 2 * np.pi, 100)
pred_plot = model.predict({"x": x_plot}, se=True)
plot_data = [
{"x": float(x_plot[i]), "fit": float(pred_plot.values[i]),
"lower": float(pred_plot.values[i] - 1.96 * pred_plot.se[i]),
"upper": float(pred_plot.values[i] + 1.96 * pred_plot.se[i])}
for i in range(len(x_plot))
]
pts = alt.Chart(
{"values": [{"x": float(data_from_pandas["x"][i]), "y": float(data_from_pandas["y"][i])}
for i in range(len(data_from_pandas["y"]))]}
).mark_circle(size=15, opacity=0.3, color="steelblue").encode(
x=alt.X("x:Q", title="x"), y=alt.Y("y:Q", title="y"),
)
band = alt.Chart({"values": plot_data}).mark_area(
opacity=0.2, color="firebrick"
).encode(x="x:Q", y="lower:Q", y2="upper:Q")
line = alt.Chart({"values": plot_data}).mark_line(
color="firebrick", strokeWidth=2
).encode(x="x:Q", y="fit:Q")
(band + pts + line).properties(
width="container", height=300, title="Predictions from the fitted GAM"
)
Missing values
GAMs do not support missing values in model covariates. If your data contains NaN values, Whittaker will raise an error at fit time. Remove or impute missing values before fitting.
# Example: cleaning missing values before fitting
rng = np.random.default_rng(23)
x_with_nans = np.array([1.0, 2.0, np.nan, 4.0, 5.0, np.nan, 7.0, 8.0, 9.0, 10.0])
y_with_nans = np.sin(x_with_nans)
# Drop rows with NaN in any column
mask = np.isfinite(x_with_nans) & np.isfinite(y_with_nans)
clean_data = {
"x": x_with_nans[mask],
"y": y_with_nans[mask],
}
print(f"Original: {len(x_with_nans)} rows")
print(f"After cleaning: {len(clean_data['x'])} rows")
Original: 10 rows
After cleaning: 8 rows
Data types
All values are converted to float64 internally. Integer arrays, boolean arrays, and other numeric types are converted automatically. Non-numeric data (strings, objects) cannot be used directly. Encode categorical variables as numeric indicators before passing them to Whittaker.
# Integer data is converted to float automatically
data_int = {
"x": np.arange(50),
"y": np.random.default_rng(23).poisson(3, 50).astype(float),
}
model = wk.GAM("y ~ s(x)", family=wk.Poisson())
model.fit(data_int, method="REML")
print(f"Fitted with integer covariates: EDF = {model.edf_total:.1f}")
Fitted with integer covariates: EDF = 2.0
The integer array is converted internally and the model fits as expected.
Functional covariates
For functional regression, functional covariates are passed as 2-D arrays in the data dictionary. Each row is one observation’s curve, and each column is one grid point along the functional domain.
# Functional covariate: each row is a curve observed at 50 grid points
rng = np.random.default_rng(23)
n, T = 100, 50
X_func = rng.normal(0, 1, (n, T)).cumsum(axis=1) / np.sqrt(T)
# The data dict can mix 1-D (scalar) and 2-D (functional) arrays
data_func = {
"curves": X_func, # shape (100, 50), functional covariate
"y": rng.normal(0, 1, n), # shape (100,), scalar response
}
print(f"Functional covariate shape: {data_func['curves'].shape}")
print(f"Response shape: {data_func['y'].shape}")
Functional covariate shape: (100, 50)
Response shape: (100,)
The 2-D array stores one curve per row. See the functional regression page for the full workflow of fitting and interpreting functional GAMs.