Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Comparing LoessSmoother with R’s loess

This document demonstrates the usage of the LoessSmoother class in scatter_smooth and compares it with the standard loess function in R.

Setup

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scatter_smooth.datasets import load_bikeshare
from scatter_smooth import LoessSmoother

%load_ext rpy2.ipython

Loading the Data

We use the same Bikeshare dataset as in the Spline comparison.

Bike = pd.read_csv(load_bikeshare())
if 'bikers' not in Bike.columns:
    Bike['bikers'] = Bike['cnt']

hr_numeric = pd.to_numeric(Bike['hr'])
bikers = Bike['bikers']

# Subsample for performance (Naive Loess is O(N^2))
n_samples = 500
idx = np.random.choice(len(hr_numeric), n_samples, replace=False)
x_sub = hr_numeric.iloc[idx].values
y_sub = bikers.iloc[idx].values

# Sort for plotting
sort_idx = np.argsort(x_sub)
x_sub = x_sub[sort_idx]
y_sub = y_sub[sort_idx]

x_plot = np.linspace(x_sub.min(), x_sub.max(), 100)

Fitting Loess Models

1. Using scatter_smooth (Python)

We fit a Loess smoother with span=0.75 and degree=1 (linear local regression).

# Fit model
loess_py = LoessSmoother(x=x_sub, span=0.75, degree=1)
loess_py.smooth(y_sub)

# Predict
y_py = loess_py.predict(x_plot)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[3], line 3
      1 # Fit model
      2 loess_py = LoessSmoother(x=x_sub, span=0.75, degree=1)
----> 3 loess_py.smooth(y_sub)
      5 # Predict
      6 y_py = loess_py.predict(x_plot)

File ~/work/scatter_smooth/scatter_smooth/scatter_smooth/loess.py:54, in LoessSmoother.smooth(self, y, sample_weight)
     52 if sample_weight is not None:
     53     self.update_weights(sample_weight)
---> 54 self._cpp_fitter.set_y(self.y)
     55 self._coef = self._intercept = self._y_hat_train = None

AttributeError: 'scatter_smooth._scatter_smooth_extension.LoessSmootherCpp' object has no attribute 'set_y'

2. Using loess (R)

We fit the same model using R. Note that R’s loess defaults to degree=2. We set degree=1 to match our Python model. We also use family="gaussian" to ensure least-squares fitting (no robust iterations).

%%R -i x_sub -i y_sub -i x_plot -o y_r
# Fit model in R
# surface="direct" calculates exact values (slow but accurate comparison)
fit_r <- loess(y_sub ~ x_sub, span=0.75, degree=1, family="gaussian", surface="direct")

# Predict
y_r <- predict(fit_r, newdata=x_plot)

Comparison

Let’s visualize the results.

fig, ax = plt.subplots(figsize=(10, 6))
ax.scatter(x_sub, y_sub, s=10, c='lightgray', alpha=0.7, label='Data')
ax.plot(x_plot, y_py, 'b-', lw=3, label='Python (LoessSmoother)', alpha=0.8)
ax.plot(x_plot, y_r, 'r--', lw=3, label='R (loess)', alpha=0.8)
ax.set_xlabel("Hour")
ax.set_ylabel("Number of Bikers")
ax.set_title("Comparison of Loess Smoothing (span=0.75, degree=1)")
ax.legend()
plt.show()

# Numerical comparison
# NaNs might appear at boundaries if weights are 0, handle them
valid = ~np.isnan(y_py) & ~np.isnan(y_r)
diff = np.mean(np.abs(y_py[valid] - y_r[valid]))
print(f"Mean Absolute Difference: {diff:.6f}")

Higher Degree Polynomials

We can also fit a local quadratic model (degree=2).

# Python
degree = 2
loess_py_deg2 = LoessSmoother(x=x_sub, span=0.75, degree=degree)
loess_py_deg2.smooth(y_sub)
y_py_deg2 = loess_py_deg2.predict(x_plot)

# R
%%R -i x_sub -i y_sub -i x_plot -o y_r_deg2 -i degree
fit_r_deg2 <- loess(y_sub ~ x_sub, span=0.75, degree=degree, family="gaussian", surface="direct")
y_r_deg2 <- predict(fit_r_deg2, newdata=x_plot)
fig, ax = plt.subplots(figsize=(10, 6))
ax.scatter(x_sub, y_sub, s=10, c='lightgray', alpha=0.7)
ax.plot(x_plot, y_py_deg2, 'b-', lw=3, label=f'Python (degree={degree})')
ax.plot(x_plot, y_r_deg2, 'r--', lw=3, label=f'R (degree={degree})')
ax.legend()
plt.title(f"Comparison of Loess (degree={degree})")
plt.show()

valid = ~np.isnan(y_py_deg2) & ~np.isnan(y_r_deg2)
diff = np.mean(np.abs(y_py_deg2[valid] - y_r_deg2[valid]))
print(f"Mean Absolute Difference (Degree 2): {diff:.6f}")

Speed Comparison

We compare the execution time for fitting and predicting with the Loess model. The scatter_smooth implementation uses an optimized C++ backend. We compare it against R’s loess with surface="direct" (exact calculation).

%%timeit
loess_py = LoessSmoother(x=x_sub, span=0.75, degree=1)
loess_py.smooth(y_sub); 
loess_py.predict(x_plot)
%%R -i x_sub -i y_sub -i x_plot
if (!require("microbenchmark", quietly = TRUE)) {
    install.packages("microbenchmark", repos="http://cloud.r-project.org")
}
library(microbenchmark)

cat(sprintf("R Timing (n=%d, surface='direct'):\n", length(x_sub)))
summary(microbenchmark(
  {
      fit <- loess(y_sub ~ x_sub, span=0.75, degree=1, family="gaussian", surface="direct")
      predict(fit, newdata=x_plot)
  },
  times=10), unit='milliseconds')[,-1]

Larger Scale Comparison (N=5000)

We increase the sample size to 5000 to see the performance difference more clearly.

n_large = 10000
x_large = np.sort(np.random.uniform(0, 10, n_large))
y_large = np.sin(x_large) + np.random.normal(0, 1, n_large)
x_plot_large = np.linspace(0, 10, 200)

The implementation here doesn’t compute fitted values at the original x unless needed.

%%timeit 
loess_cpp_large = LoessSmoother(x_large, span=0.75, degree=1)
loess_cpp_large.smooth(y_large)
loess_cpp_large.predict(x_plot_large)

If we include the cost of predicting at all of x_large we see the price:

%%timeit 
loess_cpp_large = LoessSmoother(x_large, span=0.75, degree=1)
loess_cpp_large.smooth(y_large)
loess_cpp_large.predict(x_plot_large)
loess_cpp_large.predict(x_large)
%%R -i n_large -i y_large -i x_large -i x_plot_large

cat(sprintf("\nR Timing (N=%d):\n", n_large))
summary(microbenchmark(
  {
      fit <- loess(y_large ~ x_large, span=0.75, degree=1, family="gaussian", surface="direct")
      predict(fit, newdata=x_plot_large)
  },
  times=5), unit='milliseconds')[,-1]

Let’s try quadratic as well

%%timeit 
loess_cpp_large = LoessSmoother(x_large, span=0.75, degree=2)
loess_cpp_large.smooth(y_large)
loess_cpp_large.predict(x_plot_large)

Including all the values is noticably slower

%%timeit 
loess_cpp_large = LoessSmoother(x_large, span=0.5, degree=2)
loess_cpp_large.smooth(y_large)
loess_cpp_large.predict(x_plot_large)
loess_cpp_large.predict(x_large)
loess_cpp_large = LoessSmoother(x_large, span=0.5, degree=2)
loess_cpp_large.smooth(y_large)
y_plot_py = loess_cpp_large.predict(x_plot_large)
%%R -o y_plot_r -i degree

fit <- loess(y_large ~ x_large, span=0.5, degree=2, family="gaussian", surface="direct")
y_plot_r <- predict(fit, newdata=x_plot_large)
summary(microbenchmark(
  {
      fit <- loess(y_large ~ x_large, 
                   span=0.75, 
                   degree=2, 
                   family="gaussian", 
                   surface="direct")
      predict(fit, newdata=x_plot_large)
  },
  times=5), unit='seconds')[,-1]
fig, ax = plt.subplots(figsize=(10, 6))
ax.scatter(x_large, y_large, s=10, c='lightgray', alpha=0.7)
ax.plot(x_plot_large, y_plot_py, 'b-', lw=3, label=f'Python (degree=2)')
ax.plot(x_plot_large, y_plot_r, 'r--', lw=3, label=f'R (degree=2)')
ax.legend()
plt.title(f"Comparison of Loess (degree={degree})")
plt.show()

valid = ~np.isnan(y_plot_py) & ~np.isnan(y_plot_r)
diff = np.mean(np.abs(y_plot_py[valid] - y_plot_r[valid]))
print(f"Mean Absolute Difference (Degree 2): {diff:.6f}")