Python 数据科学手册 5.6 线性回归

2022-12-01 17:25:58 浏览数 (1)

5.6 线性回归

原文:In Depth: Linear Regression 译者:飞龙 协议:CC BY-NC-SA 4.0 译文没有得到原作者授权,不保证与原文的意思严格一致。

就像朴素贝叶斯(之前在朴素贝叶斯分类中讨论)是分类任务的一个很好的起点,线性回归模型是回归任务的一个很好的起点。 这些模型受欢迎,因为它们可以快速拟合,并且非常可解释。 你可能熟悉线性回归模型的最简单形式(即使用直线拟合数据),但是可以扩展这些模型,来建模更复杂的数据行为。

在本节中,在这个众所周知问题背后,我们将从数学的快速直观的了解开始,然后再看看如何将线性模型推广到数据中更复杂的模式。

我们以标准导入来开始:

代码语言:javascript复制
%matplotlib inline
import matplotlib.pyplot as plt
import seaborn as sns; sns.set()
import numpy as np

简单线性回归

我们以最熟悉的线性回归开始,它是一个拟合数据的直线。拟合直线是这样:

代码语言:javascript复制
y = ax   b

其中a是众所周知的斜率,b是众所周知的截距。

考虑下面的数据,它点缀在直线周围,斜率为 2,截距为 -5。

代码语言:javascript复制
rng = np.random.RandomState(1)
x = 10 * rng.rand(50)
y = 2 * x - 5   rng.randn(50)
plt.scatter(x, y);

我们可以使用 Scikit-Learn 的LinearRegression估计其来拟合这个直线,并且构造出最佳拟合直线。

代码语言:javascript复制
from sklearn.linear_model import LinearRegression
model = LinearRegression(fit_intercept=True)

model.fit(x[:, np.newaxis], y)

xfit = np.linspace(0, 10, 1000)
yfit = model.predict(xfit[:, np.newaxis])

plt.scatter(x, y)
plt.plot(xfit, yfit);

数据的斜率和截距包含在模型的拟合参数中,其中 Scikit-Learn 总是以尾部的下划线标记。 这里的相关参数是coef_intercept_

代码语言:javascript复制
print("Model slope:    ", model.coef_[0])
print("Model intercept:", model.intercept_)
代码语言:javascript复制
Model slope:     2.02720881036
Model intercept: -4.99857708555

我们看到结果非常接近输入,这是我们希望的。

然而,线性回归估计器比这更加强大,除了简单的直线拟合之外,它还可以处理这种形式的多维线性模型。

代码语言:javascript复制
y = a0   a1x1   a2x2   ...

其中有多个x值。 在几何学上,这类似于使用平面拟合三维点,或使用超平面拟合更高维度的点。

这种回归的多维本质使得它们更难以可视化,但是我们可以通过使用 NumPy 的矩阵乘法运算符,构建一些示例数据来查看其中的一个拟合:

代码语言:javascript复制
rng = np.random.RandomState(1)
X = 10 * rng.rand(100, 3)
y = 0.5   np.dot(X, [1.5, -2., 1.])

model.fit(X, y)
print(model.intercept_)
print(model.coef_)
代码语言:javascript复制
0.5
[ 1.5 -2.   1. ]

这里数据y由三个随机x值构成,线性回归恢复了用于构建数据的系数。

以这种方式,我们可以使用单个LinearRegression估计器来将数据拟合为直线,平面或超平面。 这种方法似乎仍然限制于变量之间的严格线性关系,但事实证明,我们也可以使其宽松。

基函数回归

用于将线性回归适配变量之间的非线性关系的一个技巧是,根据基函数来转换数据。在超参数和模型验证和特征工程中使用的PolynomialRegression流水线中,我们已经看到了其中的一个版本。这个想法是使用我们的多维线性模型:

代码语言:javascript复制
y = a0   a1x1   a2x2   ...

并从我们的一维输入x中构造x1x2x3,以及其他。也就是,我们让xn = fn(x),其中fn是转换数据的函数。

例如,如果fn(x) = x ** n,我们的模型就变成了多项式回归:

代码语言:javascript复制
y = a0   a1x   a2x^2   a3x^3   ...

要注意这仍然是个线性模型 – 线性是指,参数an永远不会互相相乘或者相除。我们所做的是选取我们的一维x值并投影到更高维度上,以便线性拟合可以拟合xy的更复杂关系。

多项式基函数

多项式投影足够实用,它内建于 Scikit-Learn,使用PolynomialFeatures转换器。

代码语言:javascript复制
from sklearn.preprocessing import PolynomialFeatures
x = np.array([2, 3, 4])
poly = PolynomialFeatures(3, include_bias=False)
poly.fit_transform(x[:, None])
代码语言:javascript复制
array([[  2.,   4.,   8.],
       [  3.,   9.,  27.],
       [  4.,  16.,  64.]])

我们在这里看到,通过取每个值的指数,转换器将我们的一维数组转换为三维数组。 然后这种新的更高维度的数据表示,可以用于线性回归。

我们在特征工程中看到,实现这一目标的最简单的方法是使用流水线。我们以这种方式制作一个 7 阶多项式模型:

代码语言:javascript复制
from sklearn.pipeline import make_pipeline
poly_model = make_pipeline(PolynomialFeatures(7),
                           LinearRegression())

通过这种转换,我们可以使用线性模型来拟合xy之间更复杂的关系。 例如,这里是一个带有噪音的正弦波:

代码语言:javascript复制
rng = np.random.RandomState(1)
x = 10 * rng.rand(50)
y = np.sin(x)   0.1 * rng.randn(50)

poly_model.fit(x[:, np.newaxis], y)
yfit = poly_model.predict(xfit[:, np.newaxis])

plt.scatter(x, y)
plt.plot(xfit, yfit);

使用 7 阶的多项式基函数,我们的线性模型可以提供这个非线性数据的优秀的拟合。

高斯基函数

当然,也存在其他的基函数。 例如,一个有用的模式是拟合一个模型,它不是多项式基数的和,而是高斯基数的和。 结果可能如下图所示:

绘图中的阴影区域是缩放的基函数,并且当它们相加时,它们通过数据再现平滑曲线。 这些高斯基函数不内置在 Scikit-Learn 中,但是我们可以编写一个自定义的转换器来创建它们,如下图所示(Scikit-Learn 转换器实现为 Python 类;阅读 Scikit-Learn 的源代码,是了解如何创建它们的好方法):

代码语言:javascript复制
from sklearn.base import BaseEstimator, TransformerMixin

class GaussianFeatures(BaseEstimator, TransformerMixin):
    """Uniformly spaced Gaussian features for one-dimensional input"""

    def __init__(self, N, width_factor=2.0):
        self.N = N
        self.width_factor = width_factor

    @staticmethod
    def _gauss_basis(x, y, width, axis=None):
        arg = (x - y) / width
        return np.exp(-0.5 * np.sum(arg ** 2, axis))

    def fit(self, X, y=None):
        # create N centers spread along the data range
        self.centers_ = np.linspace(X.min(), X.max(), self.N)
        self.width_ = self.width_factor * (self.centers_[1] - self.centers_[0])
        return self

    def transform(self, X):
        return self._gauss_basis(X[:, :, np.newaxis], self.centers_,
                                 self.width_, axis=1)

gauss_model = make_pipeline(GaussianFeatures(20),
                            LinearRegression())
gauss_model.fit(x[:, np.newaxis], y)
yfit = gauss_model.predict(xfit[:, np.newaxis])

plt.scatter(x, y)
plt.plot(xfit, yfit)
plt.xlim(0, 10);

我们把这个例子放在这里,只是为了说明多项式基函数没有任何魔法:如果你对数据的生成过程有一些直觉,它使你认为一个或另一个基可能是适当的,你就可以使用它们。

正则化

将基函数引入到我们的线性回归中,使得模型更加灵活,但也可以很快导致过拟合(参考在超参数和模型验证和特征工程中的讨论)。 例如,如果我们选择太多的高斯基函数,我们最终得到的结果看起来不太好:

代码语言:javascript复制
model = make_pipeline(GaussianFeatures(30),
                      LinearRegression())
model.fit(x[:, np.newaxis], y)

plt.scatter(x, y)
plt.plot(xfit, model.predict(xfit[:, np.newaxis]))

plt.xlim(0, 10)
plt.ylim(-1.5, 1.5);

将数据投影到 30 维的基上,该模型具有太多的灵活性,并且在由数据约束的位置之间达到极值。 如果我们绘制高斯基相对于它们的位置的系数,我们可以看到这个原因:

代码语言:javascript复制
def basis_plot(model, title=None):
    fig, ax = plt.subplots(2, sharex=True)
    model.fit(x[:, np.newaxis], y)
    ax[0].scatter(x, y)
    ax[0].plot(xfit, model.predict(xfit[:, np.newaxis]))
    ax[0].set(xlabel='x', ylabel='y', ylim=(-1.5, 1.5))

    if title:
        ax[0].set_title(title)

    ax[1].plot(model.steps[0][1].centers_,
               model.steps[1][1].coef_)
    ax[1].set(xlabel='basis location',
              ylabel='coefficient',
              xlim=(0, 10))

model = make_pipeline(GaussianFeatures(30), LinearRegression())
basis_plot(model)

该图的底部图像显示了基函数在每个位置的幅度。 当基函数重叠时,这是典型的过拟合行为:相邻基函数的系数相互排斥并相互抵消。 我们知道这样的行为是有问题的,如果我们可以通过惩罚模型参数的较大值,来限制模型中的这种尖峰。 这种惩罚被称为正则化,有几种形式。

岭回归(L2 正则化)

也许最常见的正则化形式被称为岭回归或 L2 正则化,有时也称为 Tikhonov 正则化。 这通过惩罚模型系数的平方和(第二范数)来实现;在这种情况下,模型拟合的惩罚将是:

其中α是控制惩罚强度的自由参数。 这种类型的惩罚模型是构建在 Scikit-Learn 中的Ridge估计器:

代码语言:javascript复制
from sklearn.linear_model import Ridge
model = make_pipeline(GaussianFeatures(30), Ridge(alpha=0.1))
basis_plot(model, title='Ridge Regression')

α参数基本上是控制所得模型复杂度的旋钮。 在极限α→0中,结果恢复标准线性回归; 在极限α→∞中,所有模型响应都将被抑制。 特别是岭回归的一个优点是,它的计算很高效 – 比原始线性回归模型,几乎不需要更多的计算成本。

套索回归(L1 正则化)

另一种非常常见的正则化类型称为套索,惩罚回归系数的绝对值(第一范数)之和:

虽然这在概念上非常类似于岭回归,但是结果可能会令人惊讶:例如,由于几何原因,套索回归可能的情况下倾向于稀疏模型:即,它优先将模型系数设置为恰好为零。

我们可以复制岭回归的图像,但使用 L1 归一化系数,看到这种行为:

代码语言:javascript复制
from sklearn.linear_model import Lasso
model = make_pipeline(GaussianFeatures(30), Lasso(alpha=0.001))
basis_plot(model, title='Lasso Regression')

利用套索回归的乘法,大多数系数恰好为零,功能行为由可用基函数的一小部分建模。 与岭正则化一样,α参数调整惩罚的强度,并且应通过例如交叉验证来确定(参考超参数和模型验证和特征工程中的讨论)。

示例:预测自行车流量

例如,我们来看看我们是否可以根据天气,季节和其他因素,来预测西雅图 Fremont 大桥的自行车流量。 我们已经在使用时间序列中,看到这些数据。

在本节中,我们将把自行车数据与另一个数据连接到一起,并尝试确定天气和季节因素(温度,降水和日光时间)在多大程度上影响这条路上的自行车流量。 幸运的是,NOAA 提供他们的气象站日常数据(我使用站点号码 USW00024233),我们可以轻松地使用 Pandas 连接两个数据源。 我们将执行一个简单的线性回归,将天气和其他信息与自行车计数相关联,以便估计这些参数中的任何一个的变化,如何影响特定日期的人数。

特别地,这是一个例子,说明如何将 Scikit-Learn 的工具用于统计建模框架,其中假定模型的参数具有可解释的含义。 如前所述,这不是机器学习中的标准方法,但是对于某些模型,可以这么解释。

我们开始加载两个数据集,按日期索引:

代码语言:javascript复制
# !curl -o FremontBridge.csv https://data.seattle.gov/api/views/65db-xm6k/rows.csv?accessType=DOWNLOAD
import pandas as pd
counts = pd.read_csv('FremontBridge.csv', index_col='Date', parse_dates=True)
weather = pd.read_csv('data/BicycleWeather.csv', index_col='DATE', parse_dates=True)

下面我们会计算总共的自行车日常流量,并将其放到自己的DataFrame中:

代码语言:javascript复制
daily = counts.resample('d').sum()
daily['Total'] = daily.sum(axis=1)
daily = daily[['Total']] # remove other columns

我们以前看到,使用模式通常每天都有所不同; 我们通过添加表示星期几的二元列,来解释它:

代码语言:javascript复制
days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
for i in range(7):
    daily[days[i]] = (daily.index.dayofweek == i).astype(float)

与之类似,我们可能预期,骑车人在假期表现不同。让我们为此也添加一个指标。

代码语言:javascript复制
from pandas.tseries.holiday import USFederalHolidayCalendar
cal = USFederalHolidayCalendar()
holidays = cal.holidays('2012', '2016')
daily = daily.join(pd.Series(1, index=holidays, name='holiday'))
daily['holiday'].fillna(0, inplace=True)

我们也猜测,日光的小时数也应该骑车人数。让我们使用标准的天文计算来添加这个信息。

代码语言:javascript复制
def hours_of_daylight(date, axis=23.44, latitude=47.61):
    """Compute the hours of daylight for the given date"""
    days = (date - pd.datetime(2000, 12, 21)).days
    m = (1. - np.tan(np.radians(latitude))
         * np.tan(np.radians(axis) * np.cos(days * 2 * np.pi / 365.25)))
    return 24. * np.degrees(np.arccos(1 - np.clip(m, 0, 2))) / 180.

daily['daylight_hrs'] = list(map(hours_of_daylight, daily.index))
daily[['daylight_hrs']].plot()
plt.ylim(8, 17)
# (8, 17)

我们还可以向数据中加入平均温度和总降水。 除了降水的英寸之外,我们还添加一个标志,指示一天是否干燥(降雨量为零):

代码语言:javascript复制
# temperatures are in 1/10 deg C; convert to C
weather['TMIN'] /= 10
weather['TMAX'] /= 10
weather['Temp (C)'] = 0.5 * (weather['TMIN']   weather['TMAX'])

# precip is in 1/10 mm; convert to inches
weather['PRCP'] /= 254
weather['dry day'] = (weather['PRCP'] == 0).astype(int)

daily = daily.join(weather[['PRCP', 'Temp (C)', 'dry day']])

最后,让我们添加一个从第 1 天起增加的计数器,并且测量已经过去了几年。 这将让我们度量,在每日的流量中,任何观测到的年度增长或下降:

代码语言:javascript复制
daily['annual'] = (daily.index - daily.index[0]).days / 365.

现在我们的数据有序了,我们可以看一看:

代码语言:javascript复制
daily.head()

最后,我们可以在视觉上,比较总共的和预测的自行车流量。

代码语言:javascript复制
daily[['Total', 'predicted']].plot(alpha=0.5);

很明显,我们错过了一些关键特征,特别是在夏季。 我们的特征还不完整(即,人们不仅仅根据这些,决定是否骑车去上班),或者有一些非线性关系,我们没有考虑到(例如,也许人们在高和低温度下骑行较少)。 然而,我们的粗略近似足以提供一些见解,我们可以看一下线性模型的系数,来估计每个特征对每日自行车数量的贡献:

代码语言:javascript复制
params = pd.Series(model.coef_, index=X.columns)
params
代码语言:javascript复制
Mon              504.882756
Tue              610.233936
Wed              592.673642
Thu              482.358115
Fri              177.980345
Sat            -1103.301710
Sun            -1133.567246
holiday        -1187.401381
daylight_hrs     128.851511
PRCP            -664.834882
dry day          547.698592
Temp (C)          65.162791
annual            26.942713
dtype: float64

没有一些不确定性的度量,这些数字难以解释。 我们可以使用数据的引导重采样,来快速计算这些不确定性:

代码语言:javascript复制
from sklearn.utils import resample
np.random.seed(1)
err = np.std([model.fit(*resample(X, y)).coef_
              for i in range(1000)], 0)

估计了这些误差,让我们再次看看结果:

代码语言:javascript复制
print(pd.DataFrame({'effect': params.round(0),
                    'error': err.round(0)}))
代码语言:javascript复制
              effect  error
Mon            505.0   86.0
Tue            610.0   83.0
Wed            593.0   83.0
Thu            482.0   85.0
Fri            178.0   81.0
Sat          -1103.0   80.0
Sun          -1134.0   83.0
holiday      -1187.0  163.0
daylight_hrs   129.0    9.0
PRCP          -665.0   62.0
dry day        548.0   33.0
Temp (C)        65.0    4.0
annual          27.0   18.0

我们首先看到,每周的基线的趋势相对稳定:平日比周末和假日有更多的骑车人。我们看到,每增加一小时的日光,就多出129 ± 9个骑车人; 每升高 1 摄氏度的温度,就多出65 ± 4人;干燥的日子意味着平均有548 ± 33个骑车人,每一英寸的降水意味着665 ± ​​62个人将自行车放在家里。一旦考虑到所有这些影响,我们每年都会适度增加27 ± 18新的日常骑车人。

我们的模型几乎肯定缺少一些相关信息。例如,这个模型不能解释非线性效应(如降水和低温的影响)以及每个变量内的非线性趋势(如在非常冷和非常热的温度下的骑行倾向)。此外,我们已经抛弃了一些更细致的信息(如雨天的早上和下午之间的差异),我们忽略了天数之间的相关性(例如星期二下雨可能影响周三的数值,或连续下雨后的意想不到的阳光灿烂的日子的效果)。这些都是潜在的有趣效果,如果你愿意,你现在有了开始探索它们的工具!

0 人点赞