TowardsDataScience 博客中文翻译 2016~2018(五十三)
使用 NumPy 从头构建卷积神经网络
在某些情况下,使用 ML/DL 库中已经存在的模型可能会有所帮助。但是要有更好的控制和理解,你应该试着自己实现它们。这篇文章展示了如何使用 NumPy 实现 CNN。
介绍
卷积神经网络(CNN)是用于分析图像等多维信号的最先进技术。已经有不同的库实现了 CNN,比如 TensorFlow 和 Keras。这种库将开发人员与一些细节隔离开来,只是给出一个抽象的 API,使生活变得更容易,并避免实现中的复杂性。但实际上,这些细节可能会有所不同。有时,数据科学家必须仔细检查这些细节以提高性能。在这种情况下,解决方法是构建你自己的模型的每一部分。这提供了对网络的最高级别的控制。此外,建议实现这样的模型,以便更好地理解它们。
在本文中,CNN 仅使用 NumPy 库创建。只创建了三层,分别是卷积(简称 conv)、ReLU 和最大池。涉及的主要步骤如下:
1.读取输入图像。
2.准备过滤器。
3.Conv 层:卷积每个滤波器与输入图像。
4.ReLU 图层:在特征地图上应用 ReLU 激活功能(conv 图层的输出)。
5.最大池层:在 ReLU 层的输出上应用池操作。
6.堆叠 conv、ReLU 和 max 池层。
1.读取输入图像
以下代码从 skimage Python 库中读取一个已经存在的图像,并将其转换为灰色。
**import** skimage.data# Reading the imageimg = skimage.data.chelsea()# Converting the image into gray.img = skimage.color.rgb2gray(img)
读取图像是第一步,因为后续步骤取决于输入尺寸。转换为灰色后的图像如下所示。
Figure 1. Original Gray Image. It is the Skimage image named Chelsea accessed via skimage.data.chelsea()
2.准备过滤器
下面的代码为第一个 conv 图层准备了滤波器组(简称为 l1 ):
l1_filter = numpy.zeros((2,3,3))
根据过滤器的数量和每个过滤器的大小创建零数组。创建大小为3×3的 2 个过滤器,这就是为什么零数组的大小为(2=数量 _ 过滤器,3=数量 _ 行 _ 过滤器,3=数量 _ 列 _ 过滤器)。滤波器的大小被选择为没有深度的 2D 阵列,因为输入图像是灰色的并且没有深度(即 2D)。如果图像是具有 3 个通道的 RGB,则滤镜大小必须为(3,3,3=深度)。
滤波器组的大小由上述零数组指定,而不是由滤波器的实际值指定。可以覆盖如下值来检测垂直和水平边缘。
l1_filter[0, :, :] = numpy.array([[[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]]])l1_filter[1, :, :] = numpy.array([[[1, 1, 1], [0, 0, 0], [-1, -1, -1]]])
3.Conv 层
准备好滤波器后,下一步是用它们对输入图像进行卷积。下一行使用名为 conv 的函数将图像与滤波器组进行卷积:
l1_feature_map = conv(img, l1_filter)
该函数只接受两个参数,即图像和滤波器组,如下所示。
def conv(img, conv_filter):
if len(img.shape) > 2 or len(conv_filter.shape) > 3: # Check if number of image channels matches the filter depth.
if img.shape[-1] != conv_filter.shape[-1]:
print("Error: Number of channels in both image and filter must match.")
sys.exit()
if conv_filter.shape[1] != conv_filter.shape[2]: # Check if filter dimensions are equal.
print('Error: Filter must be a square matrix. I.e. number of rows and columns must match.')
sys.exit()
if conv_filter.shape[1]%2==0: # Check if filter diemnsions are odd.
print('Error: Filter must have an odd size. I.e. number of rows and columns must be odd.')
sys.exit()
# An empty feature map to hold the output of convolving the filter(s) with the image.
feature_maps = numpy.zeros((img.shape[0]-conv_filter.shape[1]+1,
img.shape[1]-conv_filter.shape[1]+1,
conv_filter.shape[0]))
# Convolving the image by the filter(s).
for filter_num in range(conv_filter.shape[0]):
print("Filter ", filter_num + 1)
curr_filter = conv_filter[filter_num, :] # getting a filter from the bank.
"""
Checking if there are mutliple channels for the single filter.
If so, then each channel will convolve the image.
The result of all convolutions are summed to return a single feature map.
"""
if len(curr_filter.shape) > 2:
conv_map = conv_(img[:, :, 0], curr_filter[:, :, 0]) # Array holding the sum of all feature maps.
for ch_num in range(1, curr_filter.shape[-1]): # Convolving each channel with the image and summing the results.
conv_map = conv_map + conv_(img[:, :, ch_num],
curr_filter[:, :, ch_num])
else: # There is just a single channel in the filter.
conv_map = conv_(img, curr_filter)
feature_maps[:, :, filter_num] = conv_map # Holding feature map with the current filter.
return feature_maps # Returning all feature maps.
该功能首先确保每个滤镜的深度等于图像通道的数量。在下面的代码中,外部的 if 检查通道和过滤器是否有深度。如果深度已经存在,那么内部的 if 检查它们的不相等。如果不匹配,那么脚本将退出。
if len(img.shape) > 2 or len(conv_filter.shape) > 3: # Check if number of image channels matches the filter depth.
if img.shape[-1] != conv_filter.shape[-1]:
print("Error: Number of channels in both image and filter must match.")
sys.exit()
此外,过滤器的大小应该是奇数,并且过滤器尺寸相等(即,行数和列数是奇数并且相等)。根据以下两个 if 块进行检查。如果不满足这些条件,脚本将退出。
if conv_filter.shape[1] != conv_filter.shape[2]: # Check if filter dimensions are equal.
print('Error: Filter must be a square matrix. I.e. number of rows and columns must match.')
sys.exit()
if conv_filter.shape[1]%2==0: # Check if filter diemnsions are odd.
print('Error: Filter must have an odd size. I.e. number of rows and columns must be odd.')
sys.exit()
不满足上面的任何条件都证明滤波器深度适合图像,并且卷积准备好被应用。通过滤波器对图像进行卷积,首先初始化一个数组,通过根据以下代码指定其大小来保存卷积的输出(即特征图):
# An empty feature map to hold the output of convolving the filter(s) with the image.
feature_maps = numpy.zeros((img.shape[0]-conv_filter.shape[1]+1,
img.shape[1]-conv_filter.shape[1]+1,
conv_filter.shape[0]))
因为既没有步幅也没有填充,所以特征映射的大小将等于(img_rows-filter_rows+1,image_columns-filter_columns+1,num_filters ),如上面代码中所示。注意,组中的每个滤波器都有一个输出特征映射。这就是为什么滤波器组中的滤波器数量(conv _ 滤波器.形状[0] )被用来指定大小作为第三个参数。
准备好卷积运算的输入和输出后,接下来是根据以下代码应用它:
# Convolving the image by the filter(s).
for filter_num in range(conv_filter.shape[0]):
print("Filter ", filter_num + 1)
curr_filter = conv_filter[filter_num, :] # getting a filter from the bank.
"""
Checking if there are mutliple channels for the single filter.
If so, then each channel will convolve the image.
The result of all convolutions are summed to return a single feature map.
"""
if len(curr_filter.shape) > 2:
conv_map = conv_(img[:, :, 0], curr_filter[:, :, 0]) # Array holding the sum of all feature maps.
for ch_num in range(1, curr_filter.shape[-1]): # Convolving each channel with the image and summing the results.
conv_map = conv_map + conv_(img[:, :, ch_num],
curr_filter[:, :, ch_num])
else: # There is just a single channel in the filter.
conv_map = conv_(img, curr_filter)
feature_maps[:, :, filter_num] = conv_map # Holding feature map with the current filter.
return feature_maps # Returning all feature maps.
外部循环对滤波器组中的每个滤波器进行迭代,并根据以下代码行返回它以进行进一步的步骤:
curr_filter = conv_filter[filter_num, :] # getting a filter from the bank.
如果要卷积的图像有不止一个通道,那么滤波器的深度必须等于这些通道的数量。在这种情况下,卷积是通过将每个图像通道与其在滤波器中的对应通道进行卷积来完成的。最后,结果的总和将是输出特征图。如果图像只有一个通道,那么卷积将是直接的。确定这样的行为是在这样的 if-else 块中完成的:
if len(curr_filter.shape) > 2:
conv_map = conv_(img[:, :, 0], curr_filter[:, :, 0]) # Array holding the sum of all feature maps.
for ch_num in range(1, curr_filter.shape[-1]): # Convolving each channel with the image and summing the results.
conv_map = conv_map + conv_(img[:, :, ch_num],
curr_filter[:, :, ch_num])
else: # There is just a single channel in the filter.
conv_map = conv_(img, curr_filter)
你可能会注意到,卷积是由一个名为 conv_ 的函数应用的,它不同于 conv 函数。函数 conv 只接受输入图像和滤波器组,但不应用自己的卷积。它只是将每组输入滤波器对传递给 conv_ 函数进行卷积。这只是为了使代码更容易研究。下面是 conv_ 函数的实现:
def conv_(img, conv_filter):
filter_size = conv_filter.shape[1]
result = numpy.zeros((img.shape))
#Looping through the image to apply the convolution operation.
for r in numpy.uint16(numpy.arange(filter_size/2.0,
img.shape[0]-filter_size/2.0+1)):
for c in numpy.uint16(numpy.arange(filter_size/2.0,
img.shape[1]-filter_size/2.0+1)):
"""
Getting the current region to get multiplied with the filter.
How to loop through the image and get the region based on
the image and filer sizes is the most tricky part of convolution.
"""
curr_region = img[r-numpy.uint16(numpy.floor(filter_size/2.0)):r+numpy.uint16(numpy.ceil(filter_size/2.0)),
c-numpy.uint16(numpy.floor(filter_size/2.0)):c+numpy.uint16(numpy.ceil(filter_size/2.0))]
#Element-wise multipliplication between the current region and the filter.
curr_result = curr_region * conv_filter
conv_sum = numpy.sum(curr_result) #Summing the result of multiplication.
result[r, c] = conv_sum #Saving the summation in the convolution layer feature map.
#Clipping the outliers of the result matrix.
final_result = result[numpy.uint16(filter_size/2.0):result.shape[0]-numpy.uint16(filter_size/2.0),
numpy.uint16(filter_size/2.0):result.shape[1]-numpy.uint16(filter_size/2.0)]
return final_result
它对图像进行迭代,并根据以下代码行提取与过滤器大小相等的区域:
curr_region = img[r-numpy.uint16(numpy.floor(filter_size/2.0)):r+numpy.uint16(numpy.ceil(filter_size/2.0)),
c-numpy.uint16(numpy.floor(filter_size/2.0)):c+numpy.uint16(numpy.ceil(filter_size/2.0))]
然后,它在区域和过滤器之间应用元素级乘法,并对它们求和,以获得作为输出的单个值,如下所示:
#Element-wise multipliplication between the current region and the filter.
curr_result = curr_region * conv_filter
conv_sum = numpy.sum(curr_result) #Summing the result of multiplication.
result[r, c] = conv_sum #Saving the summation in the convolution layer feature map.
在通过输入对每个滤波器进行卷积之后,特征图由 conv 函数返回。图 2 显示了此类 conv 图层返回的要素地图。
Figure 2. Output feature maps of the first conv layer.
该层的输出将被应用到 ReLU 层。
4.ReLU 层
ReLU 图层对 conv 图层返回的每个要素地图应用 ReLU 激活函数。根据下面的代码行,使用 relu 函数调用它:
l1_feature_map_relu = relu(l1_feature_map)
relu 功能实现如下:
def relu(feature_map):
#Preparing the output of the ReLU activation function.
relu_out = numpy.zeros(feature_map.shape)
for map_num in range(feature_map.shape[-1]):
for r in numpy.arange(0,feature_map.shape[0]):
for c in numpy.arange(0, feature_map.shape[1]):
relu_out[r, c, map_num] = numpy.max([feature_map[r, c, map_num], 0])
return relu_out
这很简单。只需遍历特征映射中的每个元素,如果大于 0,则返回特征映射中的原始值。否则,返回 0。ReLU 层的输出如图 3 所示。
Figure 3. ReLU layer output applied to the output of the first conv layer
ReLU 层的输出被应用到 max pooling 层。
5.最大池层
最大池层接受 ReLU 层的输出,并根据以下代码行应用最大池操作:
l1_feature_map_relu_pool = pooling(l1_feature_map_relu, 2, 2)
它是使用池函数实现的,如下所示:
def pooling(feature_map, size=2, stride=2):
#Preparing the output of the pooling operation.
pool_out = numpy.zeros((numpy.uint16((feature_map.shape[0]-size+1)/stride),
numpy.uint16((feature_map.shape[1]-size+1)/stride),
feature_map.shape[-1]))
for map_num in range(feature_map.shape[-1]):
r2 = 0
for r in numpy.arange(0,feature_map.shape[0]-size-1, stride):
c2 = 0
for c in numpy.arange(0, feature_map.shape[1]-size-1, stride):
pool_out[r2, c2, map_num] = numpy.max([feature_map[r:r+size, c:c+size, map_num]])
c2 = c2 + 1
r2 = r2 +1
return pool_out
该函数接受三个输入,即 ReLU 层的输出、汇集遮罩大小和跨距。和前面一样,它只是创建一个空数组来保存该层的输出。此类数组的大小是根据 size 和 stride 参数指定的,如下所示:
pool_out = numpy.zeros((numpy.uint16((feature_map.shape[0]-size+1)/stride),
numpy.uint16((feature_map.shape[1]-size+1)/stride),
feature_map.shape[-1]))
然后,它根据使用循环变量 map_num 的外部循环,逐个通道地循环输入。对于输入中的每个通道,应用最大池操作。根据所使用的步幅和大小,该区域被剪裁,并且它的最大值根据以下行返回到输出数组中:
pool_out[r2, c2, map_num] = numpy.max([feature_map[r:r+size, c:c+size, map_num]])
下图显示了这种池层的输出。请注意,池层输出的大小小于其输入,即使它们在图表中看起来相同。
Figure 4. Pooling layer output applied to the output of the first ReLU layer
6.堆叠层
至此,具有 conv、ReLU 和 max 池层的 CNN 架构已经完成。除了前面的层之外,可能还有一些其他层需要堆叠,如下所示。
# Second conv layerl2_filter = numpy.random.rand(3, 5, 5, l1_feature_map_relu_pool.shape[-1])**print**("\n**Working with conv layer 2**")l2_feature_map = conv(l1_feature_map_relu_pool, l2_filter)**print**("\n**ReLU**")l2_feature_map_relu = relu(l2_feature_map)**print**("\n**Pooling**")l2_feature_map_relu_pool = pooling(l2_feature_map_relu, 2, 2)**print**("**End of conv layer 2**\n")
先前的 conv 层使用 3 个过滤器,它们的值是随机生成的。这就是为什么会有 3 这样的 conv 图层生成的特征图。这对于连续的 ReLU 和 pooling 层也是一样的。这些层的输出如图 5 所示。
Figure 5. Output of the second conv-ReLU-Pooling layers
# Third conv layer
l3_filter = numpy.random.rand(1, 7, 7, l2_feature_map_relu_pool.shape[-1])
print("\n**Working with conv layer 3**")
l3_feature_map = numpycnn.conv(l2_feature_map_relu_pool, l3_filter)
print("\n**ReLU**")
l3_feature_map_relu = numpycnn.relu(l3_feature_map)
print("\n**Pooling**")
l3_feature_map_relu_pool = numpycnn.pooling(l3_feature_map_relu, 2, 2)
print("**End of conv layer 3**\n")
图 6 显示了前几层的输出。先前的 conv 层只接受一个过滤器。这就是为什么只有一个要素地图作为输出。
Figure 6. Outputs of the third conv-ReLU-Pooling layers
但是要记住,每一层的输出都是下一层的输入。例如,这些行接受以前的输出作为它们的输入。
l2_feature_map = conv(l1_feature_map_relu_pool, l2_filter)l3_feature_map = conv(l2_feature_map_relu_pool, l3_filter)
7.完全码
完整代码在github(【github.com/ahmedfgad/N… Matplotlib 库的每层输出的可视化。
import skimage.data
import numpy
import matplotlib
import sysdef conv_(img, conv_filter):
filter_size = conv_filter.shape[1]
result = numpy.zeros((img.shape))
#Looping through the image to apply the convolution operation.
for r in numpy.uint16(numpy.arange(filter_size/2.0,
img.shape[0]-filter_size/2.0+1)):
for c in numpy.uint16(numpy.arange(filter_size/2.0,
img.shape[1]-filter_size/2.0+1)):
"""
Getting the current region to get multiplied with the filter.
How to loop through the image and get the region based on
the image and filer sizes is the most tricky part of convolution.
"""
curr_region = img[r-numpy.uint16(numpy.floor(filter_size/2.0)):r+numpy.uint16(numpy.ceil(filter_size/2.0)),
c-numpy.uint16(numpy.floor(filter_size/2.0)):c+numpy.uint16(numpy.ceil(filter_size/2.0))]
#Element-wise multipliplication between the current region and the filter.
curr_result = curr_region * conv_filter
conv_sum = numpy.sum(curr_result) #Summing the result of multiplication.
result[r, c] = conv_sum #Saving the summation in the convolution layer feature map.
#Clipping the outliers of the result matrix.
final_result = result[numpy.uint16(filter_size/2.0):result.shape[0]-numpy.uint16(filter_size/2.0),
numpy.uint16(filter_size/2.0):result.shape[1]-numpy.uint16(filter_size/2.0)]
return final_result
def conv(img, conv_filter):
if len(img.shape) > 2 or len(conv_filter.shape) > 3: # Check if number of image channels matches the filter depth.
if img.shape[-1] != conv_filter.shape[-1]:
print("Error: Number of channels in both image and filter must match.")
sys.exit()
if conv_filter.shape[1] != conv_filter.shape[2]: # Check if filter dimensions are equal.
print('Error: Filter must be a square matrix. I.e. number of rows and columns must match.')
sys.exit()
if conv_filter.shape[1]%2==0: # Check if filter diemnsions are odd.
print('Error: Filter must have an odd size. I.e. number of rows and columns must be odd.')
sys.exit()# An empty feature map to hold the output of convolving the filter(s) with the image.
feature_maps = numpy.zeros((img.shape[0]-conv_filter.shape[1]+1,
img.shape[1]-conv_filter.shape[1]+1,
conv_filter.shape[0]))# Convolving the image by the filter(s).
for filter_num in range(conv_filter.shape[0]):
print("Filter ", filter_num + 1)
curr_filter = conv_filter[filter_num, :] # getting a filter from the bank.
"""
Checking if there are mutliple channels for the single filter.
If so, then each channel will convolve the image.
The result of all convolutions are summed to return a single feature map.
"""
if len(curr_filter.shape) > 2:
conv_map = conv_(img[:, :, 0], curr_filter[:, :, 0]) # Array holding the sum of all feature maps.
for ch_num in range(1, curr_filter.shape[-1]): # Convolving each channel with the image and summing the results.
conv_map = conv_map + conv_(img[:, :, ch_num],
curr_filter[:, :, ch_num])
else: # There is just a single channel in the filter.
conv_map = conv_(img, curr_filter)
feature_maps[:, :, filter_num] = conv_map # Holding feature map with the current filter.
return feature_maps # Returning all feature maps.def pooling(feature_map, size=2, stride=2):
#Preparing the output of the pooling operation.
pool_out = numpy.zeros((numpy.uint16((feature_map.shape[0]-size+1)/stride),
numpy.uint16((feature_map.shape[1]-size+1)/stride),
feature_map.shape[-1]))
for map_num in range(feature_map.shape[-1]):
r2 = 0
for r in numpy.arange(0,feature_map.shape[0]-size-1, stride):
c2 = 0
for c in numpy.arange(0, feature_map.shape[1]-size-1, stride):
pool_out[r2, c2, map_num] = numpy.max([feature_map[r:r+size, c:c+size, map_num]])
c2 = c2 + 1
r2 = r2 +1
return pool_outdef relu(feature_map):
#Preparing the output of the ReLU activation function.
relu_out = numpy.zeros(feature_map.shape)
for map_num in range(feature_map.shape[-1]):
for r in numpy.arange(0,feature_map.shape[0]):
for c in numpy.arange(0, feature_map.shape[1]):
relu_out[r, c, map_num] = numpy.max([feature_map[r, c, map_num], 0])
return relu_out# Reading the image
#img = skimage.io.imread("fruits2.png")
img = skimage.data.chelsea()
# Converting the image into gray.
img = skimage.color.rgb2gray(img)# First conv layer
#l1_filter = numpy.random.rand(2,7,7)*20 # Preparing the filters randomly.
l1_filter = numpy.zeros((2,3,3))
l1_filter[0, :, :] = numpy.array([[[-1, 0, 1],
[-1, 0, 1],
[-1, 0, 1]]])
l1_filter[1, :, :] = numpy.array([[[1, 1, 1],
[0, 0, 0],
[-1, -1, -1]]])print("\n**Working with conv layer 1**")
l1_feature_map = conv(img, l1_filter)
print("\n**ReLU**")
l1_feature_map_relu = relu(l1_feature_map)
print("\n**Pooling**")
l1_feature_map_relu_pool = pooling(l1_feature_map_relu, 2, 2)
print("**End of conv layer 1**\n")# Second conv layer
l2_filter = numpy.random.rand(3, 5, 5, l1_feature_map_relu_pool.shape[-1])
print("\n**Working with conv layer 2**")
l2_feature_map = conv(l1_feature_map_relu_pool, l2_filter)
print("\n**ReLU**")
l2_feature_map_relu = relu(l2_feature_map)
print("\n**Pooling**")
l2_feature_map_relu_pool = pooling(l2_feature_map_relu, 2, 2)
print("**End of conv layer 2**\n")# Third conv layer
l3_filter = numpy.random.rand(1, 7, 7, l2_feature_map_relu_pool.shape[-1])
print("\n**Working with conv layer 3**")
l3_feature_map = conv(l2_feature_map_relu_pool, l3_filter)
print("\n**ReLU**")
l3_feature_map_relu = relu(l3_feature_map)
print("\n**Pooling**")
l3_feature_map_relu_pool = pooling(l3_feature_map_relu, 2, 2)
print("**End of conv layer 3**\n")# Graphing results
fig0, ax0 = matplotlib.pyplot.subplots(nrows=1, ncols=1)
ax0.imshow(img).set_cmap("gray")
ax0.set_title("Input Image")
ax0.get_xaxis().set_ticks([])
ax0.get_yaxis().set_ticks([])
matplotlib.pyplot.savefig("in_img.png", bbox_inches="tight")
matplotlib.pyplot.close(fig0)# Layer 1
fig1, ax1 = matplotlib.pyplot.subplots(nrows=3, ncols=2)
ax1[0, 0].imshow(l1_feature_map[:, :, 0]).set_cmap("gray")
ax1[0, 0].get_xaxis().set_ticks([])
ax1[0, 0].get_yaxis().set_ticks([])
ax1[0, 0].set_title("L1-Map1")ax1[0, 1].imshow(l1_feature_map[:, :, 1]).set_cmap("gray")
ax1[0, 1].get_xaxis().set_ticks([])
ax1[0, 1].get_yaxis().set_ticks([])
ax1[0, 1].set_title("L1-Map2")ax1[1, 0].imshow(l1_feature_map_relu[:, :, 0]).set_cmap("gray")
ax1[1, 0].get_xaxis().set_ticks([])
ax1[1, 0].get_yaxis().set_ticks([])
ax1[1, 0].set_title("L1-Map1ReLU")ax1[1, 1].imshow(l1_feature_map_relu[:, :, 1]).set_cmap("gray")
ax1[1, 1].get_xaxis().set_ticks([])
ax1[1, 1].get_yaxis().set_ticks([])
ax1[1, 1].set_title("L1-Map2ReLU")ax1[2, 0].imshow(l1_feature_map_relu_pool[:, :, 0]).set_cmap("gray")
ax1[2, 0].get_xaxis().set_ticks([])
ax1[2, 0].get_yaxis().set_ticks([])
ax1[2, 0].set_title("L1-Map1ReLUPool")ax1[2, 1].imshow(l1_feature_map_relu_pool[:, :, 1]).set_cmap("gray")
ax1[2, 0].get_xaxis().set_ticks([])
ax1[2, 0].get_yaxis().set_ticks([])
ax1[2, 1].set_title("L1-Map2ReLUPool")matplotlib.pyplot.savefig("L1.png", bbox_inches="tight")
matplotlib.pyplot.close(fig1)# Layer 2
fig2, ax2 = matplotlib.pyplot.subplots(nrows=3, ncols=3)
ax2[0, 0].imshow(l2_feature_map[:, :, 0]).set_cmap("gray")
ax2[0, 0].get_xaxis().set_ticks([])
ax2[0, 0].get_yaxis().set_ticks([])
ax2[0, 0].set_title("L2-Map1")ax2[0, 1].imshow(l2_feature_map[:, :, 1]).set_cmap("gray")
ax2[0, 1].get_xaxis().set_ticks([])
ax2[0, 1].get_yaxis().set_ticks([])
ax2[0, 1].set_title("L2-Map2")ax2[0, 2].imshow(l2_feature_map[:, :, 2]).set_cmap("gray")
ax2[0, 2].get_xaxis().set_ticks([])
ax2[0, 2].get_yaxis().set_ticks([])
ax2[0, 2].set_title("L2-Map3")ax2[1, 0].imshow(l2_feature_map_relu[:, :, 0]).set_cmap("gray")
ax2[1, 0].get_xaxis().set_ticks([])
ax2[1, 0].get_yaxis().set_ticks([])
ax2[1, 0].set_title("L2-Map1ReLU")ax2[1, 1].imshow(l2_feature_map_relu[:, :, 1]).set_cmap("gray")
ax2[1, 1].get_xaxis().set_ticks([])
ax2[1, 1].get_yaxis().set_ticks([])
ax2[1, 1].set_title("L2-Map2ReLU")ax2[1, 2].imshow(l2_feature_map_relu[:, :, 2]).set_cmap("gray")
ax2[1, 2].get_xaxis().set_ticks([])
ax2[1, 2].get_yaxis().set_ticks([])
ax2[1, 2].set_title("L2-Map3ReLU")ax2[2, 0].imshow(l2_feature_map_relu_pool[:, :, 0]).set_cmap("gray")
ax2[2, 0].get_xaxis().set_ticks([])
ax2[2, 0].get_yaxis().set_ticks([])
ax2[2, 0].set_title("L2-Map1ReLUPool")ax2[2, 1].imshow(l2_feature_map_relu_pool[:, :, 1]).set_cmap("gray")
ax2[2, 1].get_xaxis().set_ticks([])
ax2[2, 1].get_yaxis().set_ticks([])
ax2[2, 1].set_title("L2-Map2ReLUPool")ax2[2, 2].imshow(l2_feature_map_relu_pool[:, :, 2]).set_cmap("gray")
ax2[2, 2].get_xaxis().set_ticks([])
ax2[2, 2].get_yaxis().set_ticks([])
ax2[2, 2].set_title("L2-Map3ReLUPool")matplotlib.pyplot.savefig("L2.png", bbox_inches="tight")
matplotlib.pyplot.close(fig2)# Layer 3
fig3, ax3 = matplotlib.pyplot.subplots(nrows=1, ncols=3)
ax3[0].imshow(l3_feature_map[:, :, 0]).set_cmap("gray")
ax3[0].get_xaxis().set_ticks([])
ax3[0].get_yaxis().set_ticks([])
ax3[0].set_title("L3-Map1")ax3[1].imshow(l3_feature_map_relu[:, :, 0]).set_cmap("gray")
ax3[1].get_xaxis().set_ticks([])
ax3[1].get_yaxis().set_ticks([])
ax3[1].set_title("L3-Map1ReLU")ax3[2].imshow(l3_feature_map_relu_pool[:, :, 0]).set_cmap("gray")
ax3[2].get_xaxis().set_ticks([])
ax3[2].get_yaxis().set_ticks([])
ax3[2].set_title("L3-Map1ReLUPool")matplotlib.pyplot.savefig("L3.png", bbox_inches="tight")
matplotlib.pyplot.close(fig3)
原文可在 LinkedIn 上找到,链接如下:
在某些情况下,使用 ML/DL 库中已经存在的模型可能会有所帮助。但是为了更好地控制和…
www.linkedin.com](www.linkedin.com/pulse/build…)
联系作者
艾哈迈德·法齐·加德
LinkedIn:
[## 艾哈迈德·加德-撰稿人-艾·论坛报| LinkedIn
查看 Ahmed Gad 在世界上最大的职业社区 LinkedIn 上的个人资料。艾哈迈德有 11 个工作列在他们的…
linkedin.com](linkedin.com/in/ahmedfga…)
电子邮件:
在组织中构建数据科学能力
在个人讨论或社交活动中,企业所有者、部门/职能负责人经常问我这样一个问题:“我相信数据科学的力量,但我该如何开始?”。所以这篇博文是我回答这个问题的尝试。
记住什么
在构建数据科学能力和制定路线图时,务必记住“价值必须始终领先于成本”。我看到过无法在构建数据科学能力方面创造可持续发展势头的努力,因为成本(主要是基础设施)远远超过了价值。随着成本的增加,许多人将被迫展示成果,如果没有对项目进行适当的规划,努力将无法持续。
雇佣一名经验丰富的数据科学家
是的,要开始,请雇用一个有经验的(!!!)数据科学家。为什么你可能会说是一个有经验的数据科学家,而不是那些有“数据科学家”头衔的人?经验丰富的数据科学家应该具备快速理解数据的专业知识,并确定是否有任何“唾手可得的果实”可以通过组织可以轻松访问的工具来采摘,如 Excel 或开源。这些“唾手可得的果实”与工具一起为组织提供即时(大约 1-2 个月的等待,取决于数据质量)价值。这些项目将被用来从组织的其他部分获得认同。
鉴于数据科学家是一个永久性职位,有时雇佣数据科学家可能是一个高风险的策略(数据科学家的需求量很大,所以请不要考虑尝试以合同形式雇佣)。另一种方法是聘请一位做过数据科学项目的顾问。顾问可以筛选可用的数据,并确定是否有足够的“低挂果实”。
旁注
我见过一些组织雇佣已经完成硕士或训练营的人,并希望他们知道如何处理现有数据。这些“新”学员中的大多数需要导师进一步指导,以便他们知道如何从数据中筛选见解。在数据科学中,经验真的很重要!
采摘更多水果
鉴于现有数据已经证明了足够的价值,下一步是从两个方面着手:(1)数据治理和管理(2)基础设施
(1)数据治理和管理
证明了数据的价值之后,就应该建立流程来管理数据,确保数据具有更高的质量,从而缩短从提取数据到以合适的质量使用数据之间的时间。这将允许数据快速转化为决策见解,进一步推动价值包络。
基于最初的几个项目(又名“低挂的果实”),组织现在还可以查看可以捕获哪些进一步的数据(以合理的成本),以便提高他们的洞察力。
(2)基础设施
从管理层那里获得了更多的认同后,组织现在可以在基础设施上工作了。构建基础设施通常需要更大的预算,因为需要与现有系统集成并存储数据。但是,由于我们有“低挂的果实”可以展示,现在就可以更容易地要求建立预算,管理层也将更有信心,预算将用于为组织创造更多的价值。
旁注
我在很多情况下看到,组织在没有关于如何使用它们的适当计划的情况下就购买了“大数据”技术,甚至更糟糕的是,是否有必要使用它们。最终,构建数据科学能力的势头没有持续下去,原因有多种,例如,创造的价值(如果它们是在最初创造的)不足以覆盖基础架构成本,这些组织被“白象”困住了。从这种失败的尝试中得出的结论是管理层不再相信数据科学(谁能责怪他们),这对我来说是非常可悲的,因为组织已经失去了竞争的机会。
所以记住我说的,“价值必须永远领先于成本。”
基础设施越好&数据越多,价值越大
设置基础架构和数据治理流程可能需要一些时间,比如 6 到 12 个月。在此期间,组织应该继续寻找更多的数据科学项目,为组织创造价值。有了更好的基础设施和数据质量,价值/时间花费比率将会增加。这种增长将带来另一个机会,投入更多的资源来建立更好的基础设施、更大的团队和收集更多的数据。
随着价值跑在成本前面,并确保它保持这种方式,它将创建一个良性循环,在适当的时候,数据科学能力将建立起来,并留在组织中。
结论
当然,这只是关于如何构建数据科学能力的一个非常简单的描述。考虑到不同的领域,还会有其他的考虑。但最终,我想传达的最重要的信息是“价值必须永远领先于成本”。否则,这种努力是不可持续的,组织可能会失去在这种动态和恶劣的环境中生存所必需的竞争优势。
希望博客有用!祝您的数据科学学习之旅愉快,请务必访问我的其他博客文章和 LinkedIn 个人资料。
在医疗保健中建立伦理人工智能:为什么我们必须要求它
有一个学派思考着一个黑暗的、反乌托邦式的未来,在那里,人工智能机器残酷而冷酷地统治着世界,人类只是一个生物工具。从好莱坞大片到福音派科技企业家,我们都接触到了这种未来的可能性,但我们是否都停下来思考我们应该如何避免它?当然,现在所有这些反乌托邦都是几十年后的事了,而且只是未来无数可能结果中的一个。但这并不排除今天就开始对话。
对我和许多其他人来说,这可以归结为一件简单的事情:伦理。把你的道德规范搞对了,理论上,机器永远也不能接管和支配一个机器大脑版本的宇宙。在更简单的层面上,我们需要开始考虑如何避免不人道的决定,特别是在它们有可能对我们伤害最大的地方:在医疗保健的生死环境中。我们离完全自动化的医疗保健系统还有很长的路要走,然而,现在,人工智能正在被开发来帮助增强医生的决策。但是如果其中一些决定是错误的呢?
在这篇博客中,我将讨论 NHS 最近令人震惊的新闻,这些新闻突出了为什么每个人都应该要求有道德和负责任的人工智能。
首先,我需要告诉你一个故事…
一个电子表格错误如何摧毁生命
瑞秋和她的搭档大卫(化名)都是英国国民医疗服务系统的初级医生。他们在医学院相遇,一起度过了艰难的期末考试,经历了两年医学院基础培训后的测试环境,并庆祝他们最终被各自医学专业的培训岗位录取。
雷切尔想成为一名对中风护理感兴趣的全科医生,并在英格兰北部的一家领先单位工作。大卫正在接受心理医生的培训,并在 80 英里外的一家精神病院工作。尽管很难找到一个他们都能在合理的通勤距离内生活的地方,但他们迄今为止已经通过在工作地点中间的一个村庄租一套小公寓,找到了工作与生活的某种平衡。每当需要值夜班时,雷切尔就住在离她医院更近的朋友家,有时长达一周。需要的时候,大卫呆在他父母那里。他们同意,这种安排只会持续很短的两年,直到他们找到完美的高等培训工作,最终让他们能够更紧密地合作,买房子,组建家庭。
当申请下一份工作(被称为 ST3 选择)的时候,他们兴奋地开始一起规划他们的生活。他们画了一张他们想要居住的地区的地图,在他们想要工作的所有医院,他们想要靠近的城镇和城市中标出精确的位置,并用虚线和通勤距离、抵押贷款利率和好学校的计算将它们联系起来。最后,他们找到了田园诗般的伴侣。雷切尔和大卫决定申请同一个院长职位,以增加他们匹配大型教学医院的机会,该医院也有一个精神病科,位于一个拥有广阔农村郊区的好镇上,他们觉得他们可以在那里定居并抚养孩子。
他们辛辛苦苦地填写申请表,确保收集大量参考资料,参加课外课程以充实简历。当提交日到来时,他们俩在自己的小客厅里挨着坐,腿上放着电脑,数到三后一起在对方的屏幕上点击“提交”。这就是他们的未来。他们十指交叉着上床睡觉,梦想着他们未来的共同生活。
几个星期后的采访中,结果出来了!雷切尔和大卫一起打开了他们的电子邮件,读完之后,转向对方,两个人都说“你收到了吗?”…
“是的!”,他们都喊道。他们拥抱在一起。雷切尔在大卫的怀里哭了起来,巨大的起伏纯粹是解脱的抽泣。一个半小时的通勤时间过去了,数周的夜班工作结束了。买房子的机会现在实现了!就是这样,他们要一起开始正常的生活。第二天,他们开始找房子。他们申请了抵押贷款,递交了辞呈,准备开始新的生活。一天晚上,在纸板箱和两杯喝了一半的葡萄酒的包围下,大卫单膝跪地求婚了。他答应瑞秋他们会在几个月内结婚并住在自己的房子里。很自然地,她说“是”。
然而,第二天,他们收到了一个炸弹壳 …
显然,在一些候选人的排名中存在“行政错误”,因此,向一些人提供了不正确的职位。全国所有的工作机会都被立即撤回。
瑞秋是受影响的医生之一。这个消息是在一个周五的晚上晚些时候发布的,就在一个为期三天的银行假日周末之前。三天不知道,不确定和痛苦,梦想和期望破灭。目前没有人知道最终的结果会是什么。雷切尔和大卫不得不等待,看看未来会发生什么。突然之间,他们计划好的生活感觉好像还没来得及开始就被夺走了。
因为一个不人道的错误,已经存在的和将要产生的生命实际上被搁置了。
如果这个错误确实影响了瑞秋,而且她在别处找到了一份工作,在一个很远的地方,那么他们将不得不考虑现实,放弃购买他们梦想中的房子,放弃他们今年结婚的计划,甚至现在推迟要孩子。它的不人道是压倒性的。
现在,在这个故事中,重要的是要注意到这个错误,完全真实,并影响到今天英国的初级医生,是一个脚本编程错误,而不是“人工智能”。显然,一些电子表格的格式互不相同,用于编译结果的自动化脚本没有考虑到这一点。然而,它赤裸裸地凸显了行政系统对问责制、稳健性和准确性的道德需求,尤其是因为人工智能被吹捧为非常相似的任务的替代工具。
皇家内科医生学院目前正在解决这个问题(这在 Twitter 上产生了典型的英国标签 #ST3cockup ),在撰写本文时,仍有许多像 Rachel 和 David 这样的初级医生仍在等待发现这个错误对他们和他们的家人意味着什么。
英国的初级医生越来越觉得他们是一种可消耗的资源,而不是护理系统中的人。这个错误发生在最近的Bawa Garba 医生案件(一名初级医生在极其不公平的情况下被取消行医资格)尘埃落定之时,也是在极具破坏性的初级医生因他们强加的新工作合同而罢工仅几年之后。对于一个行政系统来说,产生这样一个令人震惊的新错误,其影响仍在显现,在这样一个脆弱的时刻,听到 NHS 医疗队伍的喧嚣和失望并不奇怪。
将道德融入一切
然而,这里有一个更广泛的图景,我想重点关注:需要深入考虑自动化的潜在影响。
想到如果没有建立道德的人工智能,我们作为一个社会,就有可能将盲目的自主系统引入其中,从而做出比上面的例子更加残酷和不透明的决策,这令人心痛。人工智能没有能力理解一个更大的社会背景来评估它的错误,如果这样的管理错误是由一台机器犯的,我敢肯定骚动会更大。就目前的情况来看,很可能会有人被炒鱿鱼。但是,如果机器脑袋犯了类似的错误,它们会滚动吗?
最近,有一份由威康信托基金资助,由未来倡导撰写的精致报告发表了,关于人工智能在健康领域面临的伦理、社会和政治挑战。这篇报道中有几段话引起了我的注意:
"..一些算法错误会系统性地加重某些群体的负担,如果我们只看整体表现,这个问题就不一定明显。”
我完全同意这种说法。将它放在 ST3 申请错误的背景下,很明显,某些年轻医生群体比其他人负担更重,只有当那些受影响的人开始报告时,问题才可见。我毫不怀疑,无论是谁编写了令人不快的电子表格脚本,都已经检查过它的工作情况(即总体性能良好),但是完全不知道他们的代码能够做什么,并且很可能只有在众所周知的成功之后才被通知。
“……一个系统越开放,包括在算法工具的开发、试运行和采购方面,用户就越能感受到风险的保护……”
在这种情况下,第二种说法也很有趣。如果招聘和选拔系统更加开放、透明,甚至与受其影响的人共同设计,这个错误还会发生吗?例如,如果初级医生知道排名系统、电子表格的格式和用于编译结果的代码,会有人事先注意到吗?现在,我并不是说在这种情况下,我们应该以理想的透明度为目标——我只是提出一个问题“我们在封闭系统和透明系统之间划一条线?”
当涉及到将人工智能和自主系统转化为更具潜在危险的情况时,如生死决策(如自动驾驶汽车、癌症诊断),伦理问题就显得更大了。
我很幸运地与来自 T2 布里斯托尔机器人实验室的艾伦·温菲尔德教授相处了一段时间。他是伦理人工智能和机器人方面的杰出思想家,被广泛认为是该领域的权威。他正与 IEEE 合作,为未来技术引入道德规范创建新标准,这显然是一个热门话题!我从他那里学到的是,一个系统只有在充分考虑并包含了它可能影响的所有人之后,才是不道德的。这是一个非常有趣的想法,我认为应该全面推行。
统计是不道德的
人工智能本质上是一种计算统计学。在数学上,医疗保健中提供分类输出(例如,扫描中的“癌症”或“非癌症”)的人工智能系统可以在接收器操作者曲线(ROC 曲线)上进行评估。(放心吧,我不是要开什么统计学的讲座)。作为一个外行读者,你需要知道的是,这些曲线是一种简单的方法,可以很容易地比较系统。一个系统表现得越好,曲线就越向左上方移动。一个完美的 100%精确的系统甚至不会是一条曲线,它会是图表左上角的一个直角。
Three ROC curves: AUCc performs the best, but the area outside the ROC curve is still an ethical conundrum
在本例中,AUCc 表现最佳,ROC 曲线下面积(AUROC)约为 99%。这近乎完美,但还不够。AUCb 次之,AUROC 约为 85%,AUCa 表现最差,AUROC 约为 80%。在医疗保健领域,你可以绘制人工智能系统的 ROC 曲线,并将其与人类表现进行比较。在媒体上,当你听到一个人工智能“击败人类”时,往往是因为 AUROC 更好,或者曲线位于人类在图上操作的点的上方和左侧(我不会进一步深入语义——但相信我,这个话题有很多激烈的辩论)。
然而,AUROC 是不道德的。事实上,这是完全不道德的。它只报告积极的成功率,而完全忽略了消极的。有一个几乎没有人考虑的伦理灰色区域——我称之为 ROC 曲线(AOROC)外的区域。这个区域可以被认为代表了人工智能系统做出错误决定的所有可能时间。与一系列人类医生相比,人工智能系统的这个领域可能更小,但人类至少可以理解和认识到自己的错误,改变自己的想法,并解释他们的推理。
在上面的例子中,最佳性能系统的 AOROC 面积非常小,可能只有 1%左右。但是,当这 1%的人的自动化决策不正确时,这对他们意味着什么呢?我们如何训练系统认识到它们什么时候错了,以及如何改变它们的想法?我们如何在风险缓解中考虑这些错误以及它们可能带来的潜在生活改变效应?这些问题的答案很难回答,因此经常被忽视。
当我读到主流媒体报道“计算机在 X 击败医生”时,我很恼火,因为我知道在这条线上的某个地方,在 ROC 曲线之外的区域内的风险被忽视了,甚至被理应支持临床安全的医疗器械监管机构忽视了。我向阅读这篇文章的任何人发出挑战,让他们为我找到一个监管机构批准的人工智能系统,该系统发布了关于其失败率的统计数据,以及一份关于他们如何减轻可能受到不利影响的那些人的道德声明。我试图找到这些,但是失败了。
更糟糕的是,这些统计数据完全没有考虑到基础训练数据中的偏差。例如,当他们的高性能分类系统将黑人标记为大猩猩时,谷歌不得不公开道歉,仅仅因为训练数据没有包括白人那么多的黑人面孔。ROC 曲线永远无法证明这种类型的数据偏差,因此我们必须强制使用其他透明方法,以确保人工智能开发人员在陈述他们的性能的同时陈述他们的数据质量。
道德方面的工作已经开始
令人欣慰的是,越来越多的人一致认为,人工智能的伦理是绝对必要的。我咨询的最近一份上议院特别委员会报告强烈建议英国应该为自己打造一个独特的角色,作为伦理人工智能的先驱。
人工智能中伦理的影响现在才触及主流意识。事实上,在最近的脸书数据共享丑闻之后,公司已经建立了一个内部道德部门来调查这类问题,并且微软甚至因为道德问题而停止销售。
我会敦促任何为医疗保健开发人工智能工具的人认真审视他们系统的潜在风险,并尝试跳出框框思考如何确保道德方法。我们经常看到开发人员急于成为“第一个上市的人”,并在头条新闻中宣称他们的性能,而不关心或提及道德,我相信,当这些系统最终失败时,我们开始看到残酷的反乌托邦影响只是时间问题,他们肯定会在某个时候失败——以最近的 NHS 乳腺癌筛查错误为例。在这里,一个自动系统没有在正确的时间邀请妇女进行筛查,导致一些观察家声称多达 270 名妇女可能因此而死亡。这里的伦理分歧是惊人的,甚至已经在下议院进行了辩论。没有人知道谁应该对此负责;甚至运行系统的承包商也在别处推诿责任…
如果我们要避免一个冷酷、不人道的未来,真实的生活被自动化决策及其不可避免的错误所抛弃,我们必须现在就开始谈论并构建伦理。否则,T2 将无法从机器中拯救我们自己。
你可以点击这里了解正在上演的 ST3 传奇。
如果你和我一样对人工智能在医疗保健中的未来感到兴奋,并想讨论这些想法,请联系我们。我在推特上 @drhughharvey
如果你喜欢这篇文章,点击推荐并分享它会很有帮助。
关于作者:
Harvey 博士是一名委员会认证的放射科医生和临床学者,在英国国民医疗服务体系和欧洲领先的癌症研究机构 ICR 接受过培训,并两次获得年度科学作家奖。他曾在 Babylon Health 工作,领导监管事务团队,在人工智能支持的分诊服务中获得了世界第一的 CE 标记,现在是顾问放射科医生,皇家放射学家学会信息学委员会成员,Kheiron Medical 的临床总监,以及人工智能初创公司的顾问,包括 Algomedica 和 Smart Reporting。
Python 中 K 近邻算法的构建与改进
** 注意:自从写了这篇文章,Medium 就不再允许在 Jupyter 笔记本上嵌入代码。要查看本文的配套笔记本和代码,请访问我的 GitHub 上的** 此链接 。
K 近邻算法(K-Nearest Neighbors algorithm,简称 K-NN)是一种经典的机器学习工作马算法,这种算法在深度学习的今天往往被忽视。在本教程中,我们将在 Scikit-Learn 中构建一个 K-NN 算法,并在 MNIST 数据集上运行它。从那里,我们将建立我们自己的 K-NN 算法,希望开发一个比 Scikit-Learn K-NN 具有更好的准确性和分类速度的分类器。在这篇文章的最后,我给好奇的读者列出了一个书单,希望了解更多关于这些方法的知识。
k-最近邻分类模型
Lazy Programmer
K-最近邻算法是一种受监督的机器学习算法,它易于实现,并且具有进行稳健分类的能力。K-NN 最大的优势之一就是它是一个懒学习者。这意味着该模型不需要训练,可以直接对数据进行分类,不像它的其他 ML 兄弟,如 SVM,回归和多层感知。
K-NN 如何工作
为了对某个给定的数据点 p 进行分类,K-NN 模型将首先使用某个距离度量将 p 与其数据库中可用的所有其他点进行比较。距离度量是类似于欧几里德距离、一个简单的函数,它取两个点,并返回这两个点之间的距离。因此,可以假设它们之间距离较小的两个点比它们之间距离较大的两个点更相似。这是 K-NN 背后的中心思想。
这个过程将返回一个无序数组,其中数组中的每个条目保存了模型数据库中的 p 和 n 数据点之一之间的距离。所以返回的数组大小为 n 。这就是 K-最近邻的 K 部分的用武之地: k 是选择的某个任意值(通常在 3-11 之间),它告诉模型在对 p 分类时,它应该考虑多少个**与* p 最相似的点。然后,该模型将采用这些 k 最相似的值,并使用投票技术来决定如何对 p 进行分类,如下图所示。*
Lazy Programmer
图像中的 K-NN 模型的 k 值为 3,中间箭头指向的点就是 p ,需要分类的点。如你所见,圆圈中的三点是最接近,或者说最类似于 p 的三点。因此,使用简单的投票技术, p 将被归类为“白色”,因为白色占了 k 最相似值的大多数。
相当酷!令人惊讶的是,这个简单的算法在某些情况下可以实现超人的结果,并且可以应用于各种各样的问题,正如我们接下来将看到的那样。
在 Scikit-Learn 中实现 K-NN 算法对 MNIST 图像进行分类
数据:
对于这个例子,我们将使用无处不在的 MNIST 数据集。MNIST 数据集是机器学习中最常用的数据集之一,因为它易于实现,但却是证明模型的可靠方法。
MNIST 是一个由 7 万个手写数字组成的数据集,数字从 0 到 9。没有两个手写数字是相同的,有些很难正确分类。对 MNIST 进行分类的人类基准是大约 97.5%的准确率,所以我们的目标是超过这个数字!
算法:
我们将从 Scikit-Learn Python 库中的KNeighborsClassifier()开始。这个函数有很多参数,但是在这个例子中我们只需要关注其中的几个。具体来说,我们将只为n_neighbors参数传递一个值(这是 k 值)。weights参数给出了模型使用的投票系统的类型,其中缺省值是uniform,这意味着在分类 p 时,每个 k 点的权重相等。algorithm参数也将保留其默认值auto,因为我们希望 Scikit-Learn 找到用于分类 MNIST 数据本身的最佳算法。
下面,我嵌入了一个 Jupyter 笔记本,它用 Scikit-Learn 构建了 K-NN 分类器。开始了。
太棒了。我们使用 Scikit-Learn 建立了一个非常简单的 K 近邻模型,在 MNIST 数据集上取得了非凡的性能。
问题?对这些点进行分类花费了很长时间(对于两个数据集分别是 8 分钟和几乎 4 分钟),讽刺的是 K-NN 仍然是最快的分类方法之一。一定有更快的方法…
构建更快的模型
大多数 K-NN 模型使用欧几里德距离或曼哈顿距离作为到达距离度量。这些指标很简单,在各种情况下都表现良好。
一个很少使用的距离度量是余弦相似度。余弦相似性通常不是最佳距离度量,因为它违反了三角形不等式,并且对负数据无效。然而,余弦相似性是完美的 MNIST。它快速、简单,并且比 MNIST 上的其他距离度量标准精度略高。但是,为了尽可能地获得最佳性能,我们必须编写自己的 K-NN 模型。在我们自己制作了一个 K-NN 模型之后,我们应该会得到比 Scikit-Learn 模型更好的性能,甚至更好的准确性。让我们看看下面的笔记本,在那里我们建立了自己的 K-NN 模型。
正如笔记本中所示,我们自己制作的 K-NN 模型在分类速度(相当大的差距)和准确性(在一个数据集上提高 1%)方面都优于 Scikit-Learn K-NN!现在,我们可以继续在实践中实现这个模型,因为我们已经开发了一个真正快速的算法。
结论
这是很多,但我们学到了一些宝贵的经验。首先,我们学习了 K-NN 如何工作,以及如何轻松地实现它。但最重要的是,我们了解到,始终考虑你试图解决的问题和你可用于解决该问题的工具是很重要的。有时候,在解决问题时,最好花时间去试验——是的,建立你自己的模型。正如笔记本电脑所证明的那样,它可以带来巨大的回报:我们的第二个专有模型将使用速度提高了 1.5-2 倍,为使用该模型的实体节省了大量时间。
如果你想了解更多,我鼓励你去查看一下这个 GitHub 库,在那里你会发现这两个模型之间更彻底的分析,以及一些关于我们更快的 K-NN 模型的更有趣的特性!
书单
以下是 K-NN、通用机器学习和深度学习的有用书籍列表,以及提供人工智能和数学讨论的伟大书籍(见哥德尔、埃舍尔、巴赫)
- 统计学习的要素
- 深度学习
- 动手机器学习
- 用于数据分析的 Python
- Python 机器学习简介
- 最近邻法讲座
- 模式识别和机器学习
- 机器学习:概率视角
- 哥德尔、埃舍尔、巴赫
- 我是个奇怪的循环
- 生活 3.0
请在评论中留下您的任何意见、批评或问题!
构建机器学习工程工具
定制工具案例研究
本文的重点是展示定制的机器学习工具如何帮助简化模型构建和验证工作流程。
这是通过用一个可重用的类抽象出所有的交叉验证和绘图功能来实现的。此外,该课程允许我们并行训练和评分这些模型。最后,我们将展示如何以及为什么使用学习曲线来评估模型性能是非常有益的。
作为案例研究,我们将使用一个移动服务提供商的数据集。我们的任务是建立一个模型,可以识别预测会流失的用户。自然,在基于订阅的服务中,这些数据集是不平衡的,因为大多数用户不会在任何给定的月份取消订阅。
让我们看看这个工具如何帮助我们实现目标!
查看我的 Github 个人资料上的代码。
database-数据科学组合
github.com](github.com/DataBeast03…)
定制的 ML 工作流工具
我们将使用一个学习曲线工具,它是对 Sklearn 实现的改进
[## sk learn . model _ selection . learning _ curve-sci kit-learn 0 . 19 . 2 文档
将用于生成学习曲线的训练示例的相对或绝对数量。如果数据类型是…
scikit-learn.org](scikit-learn.org/stable/modu…)
这个自定义工具在以下方面有所改进。
在交叉验证我们的模型时,它根据 4 个分类指标(准确度、精确度、召回率和 F1 值)对模型进行评分。
Sklearn 版本只根据一个指标对模型进行评分。这意味着我们必须对相同的数据交叉验证我们的模型几次,以获得所有 4 个指标的分数,这不是一种非常明智的做法。此外,这个工具有一个方法,可以为您绘制所有 4 个指标的学习曲线。挺有用的!
# here's how you initialize the class
cv = cross_validation(model,
X_train,
Y_train ,
n_splits=10,
init_chunk_size = 100,
chunk_spacings = 25,
average = "binary") # classification model
# Training Predictors
# Training Target
# Number of KFold splits
# initial size of training set
# number of samples to increase the training set by for each iteration
# score metrics need the type of classification average specified (binary or multi-class)
这个工具如何工作
1.从 init_chunk_size 2 中指定的训练集中抽取一定数量的样本。对数据 3 执行 n_splits。在测试褶皱上对模型进行评分,并对所有褶皱的分数进行平均 5。对训练集上的模型进行评分,并对所有折叠的分数进行平均 4。将指标的平均分数存储在一个列表中,用于培训和测试折叠 5。按照 chunk_spacings 6 中指定的数量增加训练集大小。重复步骤 2 至 5,直到所有训练点都包含在训练集 7 中。调用“plot_learning_curve”方法来可视化结果
我们将通过一个例子展示这个工具是如何工作的。
标签不平衡
显然,这些类别是不平衡的:负面案例比正面案例多 6 倍。
我们的假设是,这种不平衡会对我们模型的性能产生负面影响,使模型的性能偏向多数阶级。
如果我们有一个易于阅读的可视化工具来帮助我们分析模型的性能并测试我们的假设,这不是很好吗?
这正是我们的工具做得最好的!
# split predictors from response variable
Y_churn = df.Churn.values
X_churn = df[df.columns[:-1]].values# check label balance
Counter(Y_churn)
# OUTPUT: Counter({False: 2850, True: 483})# accuracy for guessing the majoirty class every time
navie_baseline = (Y_churn.shape[0] - Y_churn.sum())/Y_churn.shape[0]
# OUTPUT: 0.85508550855085508
交叉验证:逻辑回归
# create model
lr = LogisticRegression(penalty='l2')# initialize ml tool
cv = cross_validation(lr,
X_churn,
Y_churn,
average='binary',
init_chunk_size=100,
chunk_spacings=100,
n_splits=3)# call method for model training
cv.train_for_learning_curve()# call method for ploting model results
cv.plot_learning_curve(image_name="Learning_Curve_Plot_LR", save_image=True)
让我们确保我们明白发生了什么。
训练和测试曲线上的每个点都代表了从我们指定的 K 倍计算的指标的平均分数。
请注意,每个培训分数下面都有一个相应的测试分数点(或者在某些情况下,在它上面)。这是因为我们在训练集和测试集上对模型进行评分,以查看模型在两者之间的比较情况。这将使我们能够回答关于偏差和方差的问题。所以让我们继续前进,就这样做吧!
请注意,测试集的准确度达到了 87%的峰值,考虑到最初的准确度是 85%,这并不是很高。但是等等,我们还看到测试集的精确度峰值在 50%左右,召回率峰值在 33%左右,这是为什么呢?!
好吧,记住标签是非常不平衡的。
并且度量召回是模型正确区分不同类别的能力的度量。由于标签严重失衡,相对较高的准确率和较低的召回率实际上是有意义的。准确率相对较高,因为该模型有大量的负面案例进行训练,但召回率较低,因为该模型没有足够的正面案例进行训练,因此无法学习如何区分这两者。精确度本质上是随机的:在所有预测会流失的用户中,只有一半的人真正流失了。
学习曲线还向我们表明,无论我们在训练集中包括多少点,模型都不会继续学习,即提高其性能。我们可以从每个指标的结果中看到这一点。这意味着我们的模型不适合。
在这个工具的帮助下,这难道不是一个简单的分析吗?!?我们所要做的就是传入模型、训练数据和一些参数,机器学习工作流程就会自动完成。
说说高效利用时间吧!
交叉验证:随机林
基于我们以前的结果,我们得出结论,我们需要使用一个更复杂的模型。随机森林是个不错的选择。我们将使用相同的数据和参数。
让我们比较一下现成的随机森林与我们的逻辑回归的表现。
我们可以看到,准确率跃升至 95%,召回率约为 78%,精确度约为 94%。
我们当然可以把这种表现归功于 Random Forest 的架构,即。系综树和引导聚合。
更重要的是,我们可以从学习曲线中看到,模型的性能在大约 1500 的训练集规模时饱和,并且几乎没有过度拟合:训练和测试的准确度和精确度分数非常非常接近,尽管两者之间在召回率方面有很大差距。
那么这些结果意味着什么呢?
综合起来看,回忆告诉我们,10 个搅棒中只有 7 个能与非搅棒区分开,Precison 告诉我们,在这 7 个搅棒中,10 个中有 9 个能被正确归类为搅棒。
那么我们该如何进行呢?
好吧,假设对于我们的商业目标来说,这些结果还不够好;对我们来说,在预测中犯这样的错误仍然代价太高。好的,我们有几个选择:我们可以用网格搜索改进模型,训练不同的模型,或者我们可以改进数据。
假设随机森林在召回度量上过度拟合,这表明更复杂的模型只会导致更多的过度拟合,而不太复杂的模型(即逻辑回归)会导致欠拟合。所以这意味着我们需要改进数据。为此,一个显而易见的切入点是阶级失衡。
请注意,当我们的机器学习管道高效时,我们的分析是多么轻松?我们可以花更少的时间对模型构建的基础设施进行编码,而更多地关注结果以及它们所建议的行动过程。
并行处理
在实践中,我们会将我们的重点放在平衡类上。然而,在这里,我们将用一个更复杂的模型来证明这个工具的某个功能的有效性。
支持向量机(SVM)通常需要很长时间来训练。它们是展示该工具并行处理功能价值的绝佳模型。
我们只需传入我们希望用于并行处理的 CPU 数量,然后交叉验证类将接管剩下的部分!
首先,为了真正看到效果,让我们通过将 init_chunk_size 和 chunk _ spacings 减少到 50,并将 n_splits 增加到 10 来创建更多的模型进行训练。接下来,让我们用系列交叉验证训练 SVC 来计时跑步。
start = time()
# create model
svc = SVC(C=1.0, kernel='rbf', gamma='auto')# initialize ml tool
cv_svc = cross_validation(svc,
X_churn,
Y_churn,
average='binary',
init_chunk_size=50,
chunk_spacings=50,
n_splits=10)# call method for model training
cv_svc.train_for_learning_curve()
end = time()print("Time Elapsed {:.3}".format(end - start))
# OUTPUT: Time Elapsed 64.1
现在,让我们并行地对 SVC 的交叉验证训练进行计时。
start = time()
n_cpus = 7
# create model
svc = SVC(C=1.0, kernel='rbf', gamma='auto')# initialize ml tool
cv_svc = cross_validation(svc,
X_churn,
Y_churn,
average='binary',
init_chunk_size=50,
chunk_spacings=50,
n_splits=10)# call method for model training
cv_svc.train_for_learning_curve_PARALLEL(n_cpus)
end = time()print("Time Elapsed {:.3}".format(end - start))
# OUTPUT: Time Elapsed 29.0
我们观察到的是,并行训练时,训练时间是串行训练时间的 33%。当我们拥有更大的数据集时,这将节省大量时间。
最后,正如我们所怀疑的,使用比随机森林更复杂的(现成的)模型导致了更多的过度拟合。呀!
可量测性
包含并行处理的目的是帮助这个工具可扩展到更大的数据集,但是有一个限制。该工具是中小型数据集的理想选择。
当数据集达到 TB 甚至 100 GB 时,存在一个更具可扩展性的解决方案,即 Spark。Spark 可以在后端创建一个类似的工具。
结论
通过使用构建模型来预测用户流失的案例研究,我们展示了如何使用 cross_validation 数据工具来简化机器学习模型的构建和验证过程,并且节省时间。
这是通过抽象出围绕模型构建的所有模板代码,并简单地要求开发人员传入模型、训练数据和一些参数来实现的。
我们还展示了在学习曲线上显示 4 个最常见的分类指标的价值。以及它们如何对分析偏差和方差等模型误差以及发现过度训练具有不可估量的价值。
请随意在我的 Github 帐户上叉这个项目,并让我知道你在评论中的想法!
从非结构化数据构建机器学习模型
你可能对结构化数据很熟悉,它无处不在。在这里,我想重点讨论我们如何将非结构化数据转换为数据机器可以处理的数据,然后进行推理。
从结构化数据到非结构化数据
我们可以在我们的数据库系统中找到容易结构化的数据,如个人资料记录、交易记录、项目记录。随着时间的推移,人们开始思考如何处理文本、图像、数据卫星、音频等非结构化数据。这可能会给你一些有用的东西,让你在你的业务决策。
在这种情况下,我从 kaggle 竞赛中选取了“正在烹饪什么”。竞赛要求你根据食物的成分对食物进行分类。我们将使用一些流行的库来帮助我们建立机器学习模型,Pandas,Numpy 和 Matplotlib 是你所熟悉的。
加载数据
数据清理
在这一部分中,为了 tf-idf 矢量化的目的,我将列表连接到 ingredients 中的字符串中。我假设所有的成分只有一克。
特征抽出
这里我使用 TF-IDF 矢量器和 ngram range unigram 和 bigram。拟合和转换训练数据集。在它完成了变身之后。我使用 chi2 进行特征选择,以减少维度空间。
建模
我将数据集分割成测试大小为 0.2 的训练测试分割,分割后,由于数据集存在不平衡问题,我使用 SMOTE 进行了过采样。我认为这部分非常重要,因为不平衡数据集是分类的问题,你的模型可能偏向干扰..
这次我用三个模型多项式朴素贝叶斯、支持向量机和决策树。朴素贝叶斯和决策树参数是默认值,但对于 SVM 使用
Hyperparameter SVM
估价
结果表明,SVM 最适合这一分类。
如果你喜欢这篇文章,请随意点击鼓掌按钮👏🏽如果你对接下来的帖子感兴趣,一定要在 medium 上关注我
人工智能驱动的建筑管理
你工作的地方哪个区域最忙?如果你更好地利用你的办公室,你能节省多少钱?
据统计,办公空间产生的费用仅次于人事费。与此同时,大办公室的设施利用率通常包含很大比例的浪费,如果加以监控,这些浪费是可以很容易避免的。
在无题王国,我们相信技术是我们用来支持公益事业的工具,一点一点地改变世界,让世界变得更美好。设施管理可能不是人工智能技术最性感的应用,但考虑到它在经济和生态中的作用,它是当代企业最迫切的需求之一。
今天,我们自豪地向大家介绍我们的合作伙伴,verge sense——一家由 Y CombinatorY Combinator支持的创新公司。他们的“传感器系统”智能平台使用计算机视觉来测量办公空间的利用率和占用率。我正在和丹·瑞安,VergeSense 的首席执行官兼联合创始人交谈。
Dan Ryan, the CEO and Co-founder of VergeSense
N :丹,你是怎么想出这样一个用 AI 传感器赋能楼宇管理的创新概念的?这个想法是如何发展的,你打算用维杰森解决什么问题?
我在大厦管理部门工作了大约 10 年,与许多物业经理、大厦业主或其他占用大厦的人谈过话。我们观察到的一贯问题是,建筑运营商和业主没有关于他们的建筑实际使用情况的数据。与此同时,建筑管理费用通常是这些公司的第二大成本,仅次于员工工资。
最常见的情况是资产的巨大支出,以及没有关于这些资产如何使用的信息。在这一点上,我们决定使用人工智能传感器收集关于建筑如何运行的数据,测量建筑中到底发生了什么,并且提供分析,这将帮助建筑专业人员削减开支和更有效地运营他们的办公室。
n:是什么使维珍森独一无二?你最大的优势是什么?
D: 首先,VergeSense 比我们任何竞争对手的解决方案都便宜 10-100 倍,并且更易于安装和操作。虽然我们的大多数竞争对手使用非常昂贵的有线硬件,但 VergeSense 传感器是无线的,它们依靠电池供电运行多年。
另一个优势是,VergeSense 将传感器的数据传回云端,无需费力将系统集成到企业 IT 网络中。这很容易,而且是一个真正的金钱和能源节省。
N:让 VergeSense 基于人工智能技术的目的是什么?人工智能如何提高你的解决方案的价值?
D: 我们在构建优化的神经网络方面做了大量工作,并且我们击中了靶心。VergeSense 传感器拥有 99%的数据准确性,同时,它运行在一个非常便宜的硬件上,我们不使用任何 GPU 或昂贵、复杂的机器。基于人工智能的 VergeSense 让我们创建一个高度精确的解决方案,节省时间(通常需要安装)和金钱,并提供舒适的使用。
我们还不断**提高传感器的数据收集能力。**我们目前在市场上销售的产品被认为是一种统计人数的产品,有一群客户对收集建筑中其他物体的信息感兴趣,例如,表示拿出 trush 或清理厨房的必要性。
有了 VergeSense,我们可以使用相同的硬件,并多年来更新传感器,让它收集不同类型的数据。
在解决方案的帮助下,我们能从分析数据中学到什么?
在无题王国团队的帮助下,我们花了很大力气将 VergeSense 收集的数据可视化。
该工具有两个组成部分:第一个是一个很好的,容易理解的数据可视化,允许用户比较不同类型的空间。第二个提供了一些可操作的见解,帮助用户理解正在发生的事情并采取行动。
例如,您分析数据并了解到您的一个会议室只有 10–15%被占用。在这一点上,VergeSense 会给你一些建议,比如放弃大尺寸房间,把它重新整理成几个小房间。
N:如果让你说出 VergeSense 项目最大的挑战是什么?
经营公司的头 6 个月通常是最具挑战性的,因为你基本上没有信誉可言。你正从零开始,面临最大的挑战:获得你的第一个客户。
我们很快就造出了第一个产品——我可以说那是一个基本的原型,但它已经是我们能够销售的东西了。在与 VergeSense 合作四个月后,我们接触到了第一个客户。它提供了验证产品的可能性,并确保它真正解决了我们想要解决的问题。
现在,当我们有几十个客户时,我们的主要挑战是扩大规模,撬动市场,并为我们的客户提供足够的支持。
n:数字化转型在工作环境中的重要性是什么?
D: 工作中的数字化转型是消除工作挫折感、提高生产力和增强工作舒适度的绝佳机会。使用 VergeSense 的传感器技术可以帮助人们更有效地工作。
在大型组织中,通常很难找到空闲的会议室或预订会议时段。缺少空闲空间可能是一个常见的问题,尤其是在远程工作的环境中——假设四个在线会议参与者中有一个不能出席会议。会发生什么?整个小组的会议可能会被取消。
此外,从空间中获得更高的效率有助于公司释放通常用于建筑运营的那部分资本。现在,他们获得了巨大的资本投资回报,并将这笔预算用于其他有助于业务发展的事情上。
N:你的客户对 VergeSense 有什么反馈?
D: VergeSense 的用户喜欢它的简单性和无线功能。他们称赞 VergeSense 的第二点是我们产品的准确性和获得具体入住人数的机会,以衡量房间的利用率水平。
在软件方面,我们的客户喜欢高级分析中数据可视化的简单性。我们还收到了许多热情的客户对 VergeSense UX/UI 的反馈——这正是无题王国团队真正帮助我们的地方,因为你们的设计团队非常强大,富有创造力。
听到如此热情的反馈真是太好了。您能告诉我们您的客户是如何为新平台的创建做出贡献的吗?
我们真的很快就打造出了 VergeSense MVP。在我们把它推向市场后,我们获得了很多用户的反馈。根据我们用户的提示,我们利用他们的意见重新设计并与未命名的王国团队一起重新推出了 VergeSense。
即使在今天,在我们进行的几乎每一次客户演示或致电中,我们都在收集对额外功能的请求以及对可视化数据的见解。该产品的用户界面采用模块化设计,因此很容易根据用户的建议添加组件。
N: 在 VergeSense 网站上,你提到了传感器可能带给办公室的 3 个主要价值。你能一个一个地描述他们吗?
我已经谈了很多关于 VergeSense 帮助公司更有效地管理他们的工作场所的事情。除此之外,我们的解决方案还为**带来了发现节约的机会。**无论是将大房间分割成小房间,还是决定是否需要租用一间更大的办公室,VergeSense analysis 都能让建筑经理更有效地管理预算。
最后但同样重要的是,VergeSense 传感器数据可能会与公司的生产力工具集成在一起,以增强团队的能力。
会议室在哪里?书桌在哪里? 自助餐厅排队有多长?
VergeSense 可以为您提供所有这些问题的答案,节省时间,让人们保持专注,提高他们的生产力和工作满意度。
你能想出 VergeSense 如何在你的一些客户公司提高工作效率的具体统计数据吗?
D: 对于我们的第一个客户,我们已经做了一个初步的试点项目,因此我们在 6 个月的时间里部署了传感器。我们收集了关于办公室绩效的数据,我们发现办公室的利用率非常低。尽管该团队声称他们需要更多的空间,但我们的分析显示,许多桌面区域实际上根本没有流量。利用 VergeSense 的数据,我们的客户在同一栋建筑中重新设计了另一层楼,配有更小的办公室。在此之后,我们比较了重新设计的地板与原始地板的效率。事实证明,与旧设计相比,优化后的办公室容纳了 50%的员工。当我们看房地产价格时,优化一个楼层每年为公司节省 40 万美元。
N:现在,让我们暂时跳过开发 VergeSense 的过程。在你看来,由无题王国开发者、UX/UI 设计师和产品负责人组成的团队跨职能运行你的项目有什么价值?
D: 在跨职能团队中发展 VergeSense 是非常重要的。它给了我们一个非常严格的产品开发方法。我们没有马上开始建造东西。我们一直等到真正定义了产品的用途、用户、他们面临的问题,以及我们如何帮助他们解决这些问题。《无题王国》展示了强大的产品管理纪律,在探索会议后跑一周短跑给了我们“加油,加油,加油”的感觉。
在建立一个产品后,我们向用户展示 VergeSense,征求他们的反馈,并将他们的反馈纳入到进一步的开发中,以更好地满足我们客户的需求。
你提到了你和我们团队一起参加的主题探索会议。他们如何影响重新设计的方向?
D: 在深入了解和揭示我们正在努力解决的真正客户问题方面,发现会议非常有见地。在我们写一行代码之前提前做这件事给了项目超高效率,因为在会议之后,我们确切地知道我们应该朝什么方向前进。
N:你是怎么知道无题王国的,是什么让你选择我们作为你的合作伙伴?
我想我第一次在 Y-Combinator 的论坛帖子上看到你,但也可能是 Quora 的帖子。我记不清了,但我知道我是在网上遇到你的。我已经知道波兰在技术专业和设计方面享有盛誉——之前,我曾与一些波兰开发人员和设计师合作过,波兰软件开发和 UX/UI 设计的水平给我留下了深刻的印象。
你能告诉我们一个获得风险投资的小故事吗?这条路很难走吗?
我们很早就申请了 Y-combinator,实际上甚至在我和我的联合创始人成立之前。Y-combinator 项目让我们真正接触到了硅谷的现实。在 YC 之后,我们得到了一家名为 Bolt 的风险投资公司的额外投资。从今天的角度来看,我真的为 VergeSense 的融资之旅感到自豪。但事实上,这一切都是有机发生的;背后没有秘方。老实说,当我们获得 Y- Combinator 的第一笔资金时,我感到非常震惊。
N:你能给其他正在努力融资的公司一些建议吗?
我总是不太愿意给出建议,因为每个企业都不一样,所以没有放之四海而皆准的成功之道。这些建议永远不能从一家公司转到另一家公司。但如果让我告诉其他创业者一件事,我会说:这从来都不容易。确保你真的相信你正在做的事情,如果你真的相信,不要放弃。
影响我职业生涯最重要的是坚持。在创业的世界里,会有成千上万的事情出错,会有成千上万的挑战发生。你只需要保持冷静,坚持下去,保持灵活。我们在 VergeSense 制造的第一个产品与我们最终的产品完全不同。我们倾听客户的心声,重新定义他们的需求,并根据他们的需求调整我们的解决方案。
维杰森的下一步是什么?你短期和长期的主要挑战是什么?
在短期内,我们计划面对运营挑战:扩大业务规模、收集更多订单和运送更多产品。显然,这伴随着我们团队的成长,以支持 VergeSense 客户。此外,根据用户的反馈,我们可能会找出我们想要构建的下一个关键特性。
从长远来看,我们希望找出使用我们的平台可以解决的其他问题。目前,我们正忙于 VergeSense 在房地产市场的应用。但我确信,VergeSense 传感器和分析在未来也会给其他行业带来影响。敬请关注,看我们成长!
预测患者存活率:预测
构建我的第一个数据科学项目
得到数据后,很容易立即尝试拟合几个模型并评估它们的性能。然而,首先要做的是探索性数据分析(EDA),它允许我们探索数据的结构,并理解控制变量的关系。任何 EDA 都应该包括创建和分析几个图,并创建汇总统计数据,以考虑我们的数据集中存在的模式。
如果你想知道,我是如何为这个特定的项目执行 EDA 的,你可以阅读这个 以前的帖子 。
从这个项目的 EDA 中,我们了解了数据集的一些重要特性。首先,当一个类别中的观察值总数明显低于另一个类别中的观察值时,不会出现 类别不平衡*。此外,我们的一些变量显示了偏斜度,在对它们进行对数转换后,偏斜度得到了固定,并且没有变量 显示出与其他变量的完美线性关系,尽管在其中一些变量中,我们可以观察到相互作用的趋势。*
机器学习预测
执行机器学习时要做出的一个主要决定是选择适合我们正在处理的当前问题的适当算法。
监督学习指的是从带标签的训练数据集中推断出一个函数的任务。我们将模型拟合到带标签的训练集,主要目标是找到最佳参数,这些参数将预测测试数据集中包含的新示例的未知标签。有两种主要类型的监督学习:回归,其中我们希望预测一个实数标签,以及分类,其中我们希望预测一个分类标签。 在我们的例子中,我们有一个带标签的数据集,我们想使用分类算法在分类值 0 和 1 中找到标签。
我们可以找到许多分类监督学习算法,一些简单但有效,如线性分类器或逻辑回归,另一些更复杂但功能强大,如决策树和 k-means。
在这种情况下,我们将选择随机森林算法。随机森林是最常用的机器学习算法之一,因为它非常简单、灵活和易于使用,但产生可靠的结果。
简而言之,随机森林创建了多个决策树的森林和,并集合它们以获得更准确的预测。随机森林相对于决策树的优势在于,单个模型的组合改善了整体结果,并且通过从特征的随机子集创建更小的树来防止过度拟合。**
因此,我们将首先从 scikit 加载包——了解我们需要执行随机森林,然后还要评估模型。我们还将使用 0 或 1 或NaN替换分类值,并将所有变量转换为浮点型,并对变量进行对数转换以固定偏斜度,就像我们在 EDA 中所做的那样。我们将再次检查每个变量中缺失值的总数:
在 EDA 中,我们丢弃了所有的NaN值。这里,我们需要评估处理它们的最佳方法是什么。
处理缺失数据有几种方法 但没有一种是完美的。第一步是了解数据丢失的原因。在我们的例子中,我们可以猜测分类变量中缺失的值可能是由于缺少特征,而不是作为no输入为空,或者没有进行测试。此外,连续变量中的缺失值可能是由于缺乏对该特定患者进行的生化研究,或者是因为参数在正常范围内且没有记录下来。
在这两种情况下,我们都有可能在出现时随机缺失值(该值缺失的事实与假设值无关)或时不随机缺失值(该缺失值取决于假设值)。如果是第一种情况,我们可以安全地删除NaN值,而在最后一种情况下,删除它是不安全的,因为这个丢失的值告诉我们一些关于假设值的信息。所以在我们的例子中,一旦我们要训练我们的模型,我们将估算缺失值的值。
特征缩放或数据归一化,一种用来标准化自变量范围的方法,也是训练很多分类器之前非常重要的一步。如果数据不在同一范围内,一些模型的性能会很差。随机森林的另一个优点是不需要这一步。
将数据集分为训练数据集和测试数据集
为了训练和测试我们的模型,我们需要将我们的数据集分成子数据集, 训练和测试数据集 。该模型将从训练数据集中学习,以推广到其他数据;测试数据集将用于“测试”模型在训练和拟合步骤中学到了什么。 常用 80%-20% 的规则对原始数据集进行拆分。使用可靠的方法分割数据集以避免数据泄漏是很重要的;这是存在于测试集中的例子,它们也存在于训练集中,并且可能导致过度拟合。
首先,我们将把除因变量(“Class”)之外的所有列分配给变量 X,把列“Class”分配给变量 Y。
,然后我们将从 scikit-learn 库中train_test_split把它们分成 X_train、X_test、Y_train 和 Y_test。添加random_state很重要,因为这将允许我们在每次运行代码时得到相同的结果。
**注意:训练/测试分割有一些缺点,因为一些模型需要调整超参数,在这种情况下,也在训练集中完成。避免这种情况的一种方法是创建一个规则为 60/20/20%的训练/验证/测试数据集。有几种有效的方法可以做到这一点,我们将在下面看到。
训练随机森林
现在很容易估算缺失值(使用Imputer),使用 Scikit-learn 软件包创建和训练基本随机森林模型。我们将开始应用.ravel()到 Y_train 和 Y_test 来展平我们的数组,因为不这样做将会引起我们模型的警告。
然后,我们将使用函数Imputer和策略most_frequent来估算缺失值,这将替换列(轴= 0)中最频繁出现的值的缺失值。值得注意的是,这样做可能会引入错误和偏见,但当然,正如我们之前所述,没有完美的方法来处理缺失数据。
我们的基本模型现在已经被训练,并且已经学习了我们的自变量和目标变量之间的关系。现在,我们可以通过对测试集进行预测来检查我们的模型有多好。然后,我们可以将预测与我们已知的标签进行比较。
我们将再次估算测试集中的缺失值,并使用函数predict和指标accuracy_score来评估我们模型的性能。
正如我们在上面看到的,我们的基本模型有 74.19%的准确率,这告诉我们它还需要进一步改进。
超参数调整
有几种方法可以改进我们的模型:收集更多的数据,调整模型的超参数或选择其他模型。我们将选择第二个,我们现在将调整我们的随机森林分类器的超参数。
模型参数通常在训练期间学习;然而,超参数必须在训练前手动设置。对于随机森林,超参数包括:
- n_estimators:森林中的树木数量
- max_features:每个树中的最大特征数
- max_depth:所有树的最大分割数
- bootstrap:是否实现 bootstrap 来构建树
- 标准:评估决策树的停止标准
当然,当我们实现基本的随机森林时,Scikit-learn 实现了一组默认的超参数,但是我们不确定这些参数对于我们的特定问题是否是最优的。
在这一点上,我们需要考虑两个概念:欠拟合和过拟合。 欠拟合 发生在模型过于简单,与数据拟合不太好的时候:方差小,偏差大。另一方面, 过拟合 发生在模型对训练集调整得太好而在新的例子中表现不佳的时候。如果我们调整训练数据集中的超参数,我们可能会使随机森林分类器过拟合。因此,我们将回到之前提到的:交叉验证。**
交叉验证的方法有很多,最著名的有: K 重交叉验证和留一交叉验证。在我们的例子中,我们将使用第一个:我们将把我们的数据分成 K 个不同的子集,使用 k-1 个子集作为我们的训练集,最后一个子集作为我们的测试数据。为了调整我们的超参数,我们将对 K-子集交叉验证执行多次迭代,但每次使用不同的模型设置。然后,我们比较所有的模型,选择最好的一个;然后,我们将在完整的训练集中训练最好的模型,并在测试集上对其进行评估。我们将利用 Scikit-learn 中的 GridSearchCV 包来执行这项任务。
我们将确定想要优化的参数和值,然后执行 GridSearchCV,并将获得的最佳参数设置为我们的模型。
正如我们在上面看到的,GridSearchCV 将我们的准确率从 74%提高到了 77%。尽管这不是一个很大的改进,但据报道,使用这个数据集,其他研究仅达到 80%的准确率。因此,考虑到这一点以及数据集有许多缺失数据且不大(只有 155 个样本)的事实,我们可以继续分析其他模型指标。
测试集指标
既然我们已经优化了超参数,我们将继续评估我们的模型。首先,我们将创建一个混淆矩阵,它将根据我们的预测值告诉我们真阴性、假阳性、假阴性和真阳性值,并使用 seaborn heatmap 绘制它:
真阴性(TN)|假阳性(FP) ————— 假阴性(FN)|真阳性(TP)
分析混淆矩阵,我们可以预期我们的模型显示出比精确度(TP/TP+FP)更高的召回率(TP/TP+FN ),但是两个参数都将高于精确度(TP+TN)/总数。根据我们认为我们的模型需要解决的问题,可以考虑这三个参数。我们稍后将回到这些问题上。
我们可以使用 ROC 曲线并计算曲线下的面积来进一步研究假阳性率和真阳性率,曲线下的面积也是我们模型的预测能力的度量(如果该值更接近 1,则意味着我们的模型在将随机样本区分为两类方面做得很好)。
从 ROC 曲线中,我们了解到我们的模型在区分两个类别方面做得不好,因为 auc 是 0.60。我们可以通过收集更多的数据并添加到模型中来改善这个问题。
最后,我们可以分析精确-回忆曲线:
我们可以观察到,对于不同的值,精度-召回率关系是相当恒定的,表明我们的模型具有良好的精度和召回率。这是因为与真阴性、假阳性和假阴性相比,真阳性值相当高。重要的是要记住,由于召回率和精确度的公式,当一个高,另一个低,推动我们找到一个平衡,两者对我们的模型都足够高。
解读结果
在完成我们的项目之前,我们可以做的最后一件事是评估变量的重要性,也就是量化每个变量对我们的模型有多有用。
我们可以观察到年龄、蛋白时间、alk_phosphate、胆红素、不适、腹水是我们模型的一些最重要的变量。这反映了我们之前在 EDA 中看到的情况,并强调了在开始机器学习算法之前执行这种探索性分析的重要性。
总结
因此,在将随机森林应用于我们的数据集后,我们可以得出结论,我们的最佳模型能够预测肝炎患者的存活率,准确率为 77%,精确度和召回率约为 80%。这不是最好的情况,因为我们希望我们的模型表现得更好,特别是在这种涉及患者生存的情况下。然而,中等的好结果可能是由于数据库小和大量的缺失值。
从头开始构建神经网络
使用 Python 中的 Numpy 对多层感知器的简单介绍。
在这本笔记本中,我们将使用 numpy 构建一个神经网络(多层感知器),并成功训练它识别图像中的数字。深度学习是一个庞大的主题,但我们必须从某个地方开始,所以让我们从多层感知器的神经网络的基础开始。你可以在笔记本版本这里或者我的网站找到同样的博客。
什么是神经网络?
神经网络是一种机器学习模型,它受到我们大脑中神经元的启发,其中许多神经元与许多其他神经元相连,以将输入转化为输出(简单吧?).大多数情况下,我们可以看到任何机器学习模型,并将其视为一个接受输入并产生所需输出的函数;神经网络也是一样。
什么是多层感知器?
多层感知器是一种网络类型,其中一组感知器的多个层堆叠在一起形成一个模型。在我们进入一个层和多个感知器的概念之前,让我们从这个网络的构建模块开始,它是一个感知器。将感知器/神经元视为一个线性模型,它接受多个输入并产生一个输出。在我们的例子中,感知器是一个线性模型,它接受一组输入,将它们与权重相乘,并添加一个偏差项以生成一个输出。
Fig 1: Perceptron image
Image credit = https://commons . wikimedia . org/wiki/File:perceptron . png/
现在,如果我们将这些感知机堆叠在一起,它就变成了一个隐藏层,在现代深度学习术语中也称为密集层。 密集层,
注意,偏差项现在是一个向量,W 是一个权重矩阵
Fig 2: Single dense layer perceptron network
Image credit = http://www . t example . net/tikz/examples/neural-network/
现在我们了解了密集层,让我们把它们加起来,这个网络就变成了一个多层感知器网络。
Fig 3: Multi layer perceptron network
Image credit = http://pubs . scie pub . com/ajmm/3/3/1/figure/2s
如果你已经注意到我们的稠密层,只有线性函数,并且线性函数的任何组合只导致线性输出。由于我们希望我们的 MLP 具有灵活性并学习非线性决策边界,我们还需要将非线性引入网络。我们通过添加激活函数来实现引入非线性的任务。有各种各样的激活函数可以使用,但我们将实现整流线性单位(ReLu),这是一个流行的激活函数。ReLU 函数是一个简单的函数,它对于任何小于零的输入值都是零,对于大于零的值也是相同的值。 ReLU 功能
现在,我们理解了密集层,也理解了激活函数的目的,剩下的唯一事情就是训练网络。为了训练神经网络,我们需要一个损失函数,每一层都应该有一个前馈回路和反向传播回路。前馈回路接收输入并产生输出以进行预测,反向传播回路通过调整层中的权重以降低输出损失来帮助训练模型。在反向传播中,权重更新通过使用链规则的反向传播梯度来完成,并使用优化算法来优化。在我们的例子中,我们将使用 SGD(随机梯度下降)。如果你不理解梯度权重更新和 SGD 的概念,我推荐你看 Andrew NG 讲座机器学习第一周。
因此,总结一个神经网络需要几个构件
- 致密层 —全连通层,
- ReLU layer (或任何其他引入非线性的激活功能)
- 损失函数——(多类分类问题时的交叉熵)
- 反向传播算法 —具有反向传播梯度的随机梯度下降
让我们一个一个地接近他们。
编码从这里开始:
让我们从导入创建神经网络所需的一些库开始。
from __future__ import print_function
import numpy as np ## For numerical python
np.random.seed(42)
每一层都有一个向前传递和向后传递的实现。让我们创建一个可以向前传递的主类层*。向前()和向后传球。向后()。*
class Layer:
#A building block. Each layer is capable of performing two things: #- Process input to get output: output = layer.forward(input)
#- Propagate gradients through itself: grad_input = layer.backward(input, grad_output)
#Some layers also have learnable parameters which they update during layer.backward.
def __init__(self):
# Here we can initialize layer parameters (if any) and auxiliary stuff.
# A dummy layer does nothing
pass
def forward(self, input):
# Takes input data of shape [batch, input_units], returns output data [batch, output_units]
# A dummy layer just returns whatever it gets as input.
return input def backward(self, input, grad_output):
# Performs a backpropagation step through the layer, with respect to the given input.
# To compute loss gradients w.r.t input, we need to apply chain rule (backprop):
# d loss / d x = (d loss / d layer) * (d layer / d x)
# Luckily, we already receive d loss / d layer as input, so you only need to multiply it by d layer / d x.
# If our layer has parameters (e.g. dense layer), we also need to update them here using d loss / d layer
# The gradient of a dummy layer is precisely grad_output, but we'll write it more explicitly
num_units = input.shape[1]
d_layer_d_input = np.eye(num_units)
return np.dot(grad_output, d_layer_d_input) # chain rule
非线性关系层
这是你能得到的最简单的层:它简单地将非线性应用于你的网络的每个元素。
class ReLU(Layer):
def __init__(self):
# ReLU layer simply applies elementwise rectified linear unit to all inputs
pass
def forward(self, input):
# Apply elementwise ReLU to [batch, input_units] matrix
relu_forward = np.maximum(0,input)
return relu_forward
def backward(self, input, grad_output):
# Compute gradient of loss w.r.t. ReLU input
relu_grad = input > 0
return grad_output*relu_grad
致密层
现在让我们构建一些更复杂的东西。与非线性不同,密集层实际上有东西要学。
密集层应用仿射变换。在矢量化形式中,它可以描述为:
在哪里
- x 是形状[批量大小,数量特征]的对象特征矩阵,
- w 是权重矩阵[特征数量,输出数量]
- b 是 num_outputs 偏差的向量。
W 和 b 都在层创建期间初始化,并在每次调用 backward 时更新。请注意,我们正在使用 Xavier 初始化,这是一个训练我们的模型更快收敛的技巧阅读更多。我们不是用随机分布的小数字初始化我们的权重,而是用平均值 0 和方差 2/(输入数+输出数)初始化我们的权重
class Dense(Layer):
def __init__(self, input_units, output_units, learning_rate=0.1):
# A dense layer is a layer which performs a learned affine transformation:
# f(x) = <W*x> + b
self.learning_rate = learning_rate
self.weights = np.random.normal(loc=0.0,
scale = np.sqrt(2/(input_units+output_units)),
size = (input_units,output_units))
self.biases = np.zeros(output_units)
def forward(self,input):
# Perform an affine transformation:
# f(x) = <W*x> + b
# input shape: [batch, input_units]
# output shape: [batch, output units]
return np.dot(input,self.weights) + self.biases
def backward(self,input,grad_output):
# compute d f / d x = d f / d dense * d dense / d x
# where d dense/ d x = weights transposed
grad_input = np.dot(grad_output, self.weights.T)
# compute gradient w.r.t. weights and biases
grad_weights = np.dot(input.T, grad_output)
grad_biases = grad_output.mean(axis=0)*input.shape[0]
assert grad_weights.shape == self.weights.shape and grad_biases.shape == self.biases.shape
# Here we perform a stochastic gradient descent step.
self.weights = self.weights - self.learning_rate * grad_weights
self.biases = self.biases - self.learning_rate * grad_biases
return grad_input
损失函数
由于我们希望预测概率,因此在我们的网络上定义 softmax 非线性并计算给定预测概率的损失是合乎逻辑的。但是,有一种更好的方法可以做到这一点。
如果我们将交叉熵的表达式写为 softmax logits (a)的函数,您会看到:
如果我们仔细看看,我们会发现它可以重写为:
它被称为 Log-softmax,它在各个方面都优于 naive log(softmax(a)):
- 更好的数值稳定性
- 更容易得到正确的导数
- 计算速度略微加快
那么,为什么不在我们的计算中使用 log-softmax,而不去估算概率呢?
def softmax_crossentropy_with_logits(logits,reference_answers):
# Compute crossentropy from logits[batch,n_classes] and ids of correct answers
logits_for_answers = logits[np.arange(len(logits)),reference_answers]
xentropy = - logits_for_answers + np.log(np.sum(np.exp(logits),axis=-1))
return xentropydef grad_softmax_crossentropy_with_logits(logits,reference_answers):
# Compute crossentropy gradient from logits[batch,n_classes] and ids of correct answers
ones_for_answers = np.zeros_like(logits)
ones_for_answers[np.arange(len(logits)),reference_answers] = 1
softmax = np.exp(logits) / np.exp(logits).sum(axis=-1,keepdims=True)
return (- ones_for_answers + softmax) / logits.shape[0]
全网络
现在,让我们将刚刚构建的内容结合到一个有效的神经网络中。正如我之前所说的,我们将使用手写数字的 MNIST 数据作为我们的例子。幸运的是,Keras 已经有了 numpy 数组格式的,所以让我们导入它吧!。
import keras
import matplotlib.pyplot as plt
%matplotlib inlinedef load_dataset(flatten=False):
(X_train, y_train), (X_test, y_test) = keras.datasets.mnist.load_data() # normalize x
X_train = X_train.astype(float) / 255.
X_test = X_test.astype(float) / 255. # we reserve the last 10000 training examples for validation
X_train, X_val = X_train[:-10000], X_train[-10000:]
y_train, y_val = y_train[:-10000], y_train[-10000:] if flatten:
X_train = X_train.reshape([X_train.shape[0], -1])
X_val = X_val.reshape([X_val.shape[0], -1])
X_test = X_test.reshape([X_test.shape[0], -1]) return X_train, y_train, X_val, y_val, X_test, y_testX_train, y_train, X_val, y_val, X_test, y_test = load_dataset(flatten=True)## Let's look at some example
plt.figure(figsize=[6,6])
for i in range(4):
plt.subplot(2,2,i+1)
plt.title("Label: %i"%y_train[i])
plt.imshow(X_train[i].reshape([28,28]),cmap='gray');
我们将网络定义为一系列层,每一层都应用在前一层之上。在这种情况下,计算预测和训练变得微不足道。
network = []
network.append(Dense(X_train.shape[1],100))
network.append(ReLU())
network.append(Dense(100,200))
network.append(ReLU())
network.append(Dense(200,10))def forward(network, X):
# Compute activations of all network layers by applying them sequentially.
# Return a list of activations for each layer.
activations = []
input = X # Looping through each layer
for l in network:
activations.append(l.forward(input))
# Updating input to last layer output
input = activations[-1]
assert len(activations) == len(network)
return activationsdef predict(network,X):
# Compute network predictions. Returning indices of largest Logit probability logits = forward(network,X)[-1]
return logits.argmax(axis=-1)def train(network,X,y):
# Train our network on a given batch of X and y.
# We first need to run forward to get all layer activations.
# Then we can run layer.backward going from last to first layer.
# After we have called backward for all layers, all Dense layers have already made one gradient step.
# Get the layer activations
layer_activations = forward(network,X)
layer_inputs = [X]+layer_activations #layer_input[i] is an input for network[i]
logits = layer_activations[-1]
# Compute the loss and the initial gradient
loss = softmax_crossentropy_with_logits(logits,y)
loss_grad = grad_softmax_crossentropy_with_logits(logits,y)
# Propagate gradients through the network
# Reverse propogation as this is backprop
for layer_index in range(len(network))[::-1]:
layer = network[layer_index]
loss_grad = layer.backward(layer_inputs[layer_index],loss_grad) #grad w.r.t. input, also weight updates
return np.mean(loss)
训练循环
我们将数据分成小批,将每个这样的小批输入网络并更新权重。这种训练方法被称为小批量随机梯度下降。
from tqdm import trange
def iterate_minibatches(inputs, targets, batchsize, shuffle=False):
assert len(inputs) == len(targets)
if shuffle:
indices = np.random.permutation(len(inputs))
for start_idx in trange(0, len(inputs) - batchsize + 1, batchsize):
if shuffle:
excerpt = indices[start_idx:start_idx + batchsize]
else:
excerpt = slice(start_idx, start_idx + batchsize)
yield inputs[excerpt], targets[excerpt]from IPython.display import clear_output
train_log = []
val_log = []for epoch in range(25): for x_batch,y_batch in iterate_minibatches(X_train,y_train,batchsize=32,shuffle=True):
train(network,x_batch,y_batch)
train_log.append(np.mean(predict(network,X_train)==y_train))
val_log.append(np.mean(predict(network,X_val)==y_val))
clear_output()
print("Epoch",epoch)
print("Train accuracy:",train_log[-1])
print("Val accuracy:",val_log[-1])
plt.plot(train_log,label='train accuracy')
plt.plot(val_log,label='val accuracy')
plt.legend(loc='best')
plt.grid()
plt.show()Epoch 24
Train accuracy: 1.0
Val accuracy: 0.9809
正如我们所看到的,我们已经成功地训练了一个完全用 numpy 编写的 MLP,具有很高的验证准确性!
用 F#构建神经网络—第 1 部分
Whats the next big step from linear regression?
前言
距离我上一篇关于 medium 的文章已经有一段时间了,所以这是另一篇关于神经网络的更深入的文章……用 F#写的。是的,我决定继续用机器学习的功能方式,下一步是从头开始构建这些。虽然你们中的一些人可能会抱怨函数式编程速度较慢,但它确实让你对任何问题有了不同的看法。在第一部分中,我将介绍如何设置核心函数,这些函数可以用来对任意数据集进行训练。
理论
在我们开始之前,理论上的一个旁注:我不会深究数学证明,尽管我会不时地使用几个方程。毕竟,整个问题可以归结为一些巧妙的数学运算。就我个人而言,到目前为止,我已经从亚塞尔·阿布·穆斯塔法教授的优秀的免费课程中学到了大部分理论。我将使用这些幻灯片作为基础来实现大部分功能。我还假设你有一些函数式编程的基本知识,尽管从 F# 这里开始是个好主意。在整篇文章中,我会尝试将重要概念的参考链接起来。
设置
这里的主要目的是演示一个工作的神经网络,它可以根据需要构建成多深多宽。我们将使用上次的 MSE 来计算我们的总成本,因为这简化了计算。使神经网络不同于任何任意分层的简单感知器的是激活函数,它帮助神经网络学习数据集中的非线性。有许多不同的激活函数,每一个都有其优点,但在我们的情况下,双曲正切就足够了。最后,我们将在大部分数据结构和计算中使用 MathNet 库。这对于 F#来说基本上是微不足道的(但是不可否认的是要简单得多)。
助手功能
函数式编程的关键思想是将你的主要功能分解成更小、更简单的任务,这些任务更容易实现、调试和理解。记住这一点,我们将首先创建几个将在前向和后向传播中使用的辅助函数。
让我们从为我们的函数定义一些定制的类型开始。拥有一个好的类型系统是函数式编程的关键要素之一,除了其他好处之外,它还能让你的代码看起来更整洁,更不容易出错。重点关注的主要类型是“Layer”和“NodesVect”。每个层对象可以被认为是权重矩阵的标记元组和应用于该层输出的激活函数。 wMatx 将保存从前一层到下一层的每个连接的权重(i_max = m(行),j_max = n(列))。每个节点 Vect 将由两个向量组成。 sVect 将在任意层的输出(和输入向量)通过激活函数之前保存它。在 sVect 通过激活函数后,xVect 仅保存其值。我们需要这两者来进行反向传播,所以我们不能只存储 xVect。
下图以数学形式总结了这些类型。关于偏差有一点需要注意:我们将把权重存储在权重矩阵中,但是因为它们只是前向连接的(即它们的节点值总是= 1),我们将仅在需要时将这些“动态”附加到 sVect 或 xVect 。
Image Credit: Learning From Data — Lecture 10: Neural Networks
激活功能在这里被定义为区别联合。这些与枚举非常相似,但是更加灵活,它们允许我们通过使用关键字而不是实际的函数名将激活函数列表传递给网络生成器。 getActFn 接受特定激活函数的“关键字”和一个浮点数作为输入,并返回转换后的值。
lastLayerDeriv 是计算用于反向传播的最后一层的误差增量的函数。这里使用点乘,因为我们将得到任意维输出的导数,所以我们的输出现在是向量。
任意网络生成
为了能够处理任意数量的层和每层中任意数量的节点,我们将创建一个网络生成器,它将简单地接受网络中的输入、输出和隐藏节点的数量(不包括偏差)以及激活函数类型作为两个列表,并输出一个结果 monad 。这将返回错误字符串或网络类型对象。如果节点和激活函数列表不符合要求的规范,我们将只使用结果单子来打印错误(这就是为什么你会看到这么多匹配!).这个函数几乎是不言自明的。
The network generator function
正向传播
对于神经网络,前向传递通常在概念上易于理解。简而言之,它包括将下一层中的节点值设置为前一层中的节点和偏差的加权和,并通过非线性激活函数传递该结果。
Animation showcasing List.fold (source)
在我们的 fwdProp 实现中,我们将使用 List.fold ,这是递归遍历列表的有效实现。与上图不同的是,我们的累加器不是一个单独的元素,而是一个由XV vectansvect和 Layer 类型按相反顺序排列而成的列表。我们以相反的顺序返回它们,因为 F#列表是作为单链表实现的,所以在顶部附加 O(1)(在底部附加 O(n))。这也对我们有利,因为根据定义,反向传播将以相反的顺序工作!
List.fold 允许我们设置一个初始状态,这些将是输入向量对(这是特殊情况,当 sVect == xVect 时)和一个空列表以逆序存储权重。权重矩阵( nm* )乘以 x 向量( m1* )的转置将给出下一层的 s 向量( n1* )(如上面的数学形式所示)。特别注意偏差,在当前层的 x 向量上附加“1.0”,但是按照网络的设计方式,下一层仍然需要“1.0”(因为没有任何东西馈入偏差节点)。最后,对 s 向量中的每个元素执行简单的激活函数映射,以生成下一层的 x 向量。
反向传播
该是认真对待的时候了。反向传播推导在许多在线资料中都有很好的解释。我们将使用随机梯度下降法,因此一些方程可能看起来略有不同。完整的方程式列表可以在前面提到的课堂笔记中找到。在阅读代码之前,记住这一点是个好主意。
Note: the error-delta here is a scalar corresponding to each node
我们首先将误差增量(描述归因于每个未激活输出的最终误差的贡献)计算为一个向量。我们使用前面描述的 helper 函数对最后一层进行计算。一旦我们得到这个值,我们现在需要计算所有层的误差增量矢量。严格地说,如果有什么东西进入一个层,这个层就可以被归类为这样一个层。因此,输入没有被分类为图层,计算它们的误差增量没有意义。使用当前层的权重矩阵和前一层(或输入)的 s 向量来计算前一层的误差增量。这可以使用自定义尾递归函数轻松完成。
The chain-rule formula for the final multiplication (given here on a per-weight basis)
一旦我们有了逆序的误差增量向量列表,我们需要将激活的输出(从倒数第二层开始)乘以误差增量,以产生 dE/dW 矩阵,该矩阵描述了每个权重的误差贡献,并将用于更新权重,从而允许机器从其错误中“学习”。简单地通过从原始权重矩阵中减去 dE/dW 的因子(学习率)来更新权重。为了在每层的基础上执行所有这些操作,我们使用了 List.map3 。这是一个内置的 F#函数,它允许我们同时迭代 3 个列表(权重矩阵,x 向量&误差增量向量)。这种映射的结果将按照正确的原始顺序产生更新的权重,然后可以由反向传播函数直接返回。
这里需要注意一些事情。x 向量、s 向量和 w 矩阵都以相反的顺序传递给递归函数。“Head”只返回列表中的第一个元素,“Tail”返回除第一个元素之外的所有元素。我们移除权重矩阵中的第一行,因为它对应于偏置节点,并且该节点对其后面的任何权重没有贡献,即没有权重馈入偏置节点。对xandsltrev 进行迭代。Tail 相当于迭代每个前一层的输出。
层更新器功能需要额外的 1.0 附加到 x 向量,因为这是前一层的激活输出,并且这里偏置连接到当前层中的权重分量,即权重矩阵具有 m 行,第 0 行对应于偏置权重。
结论
为自己走了这么远而感到欣慰吧!您现在可以使用这些函数从一个简单的顶级描述创建任意网络,比如:initNetwork [1 ; 2 ; 1] [ID TANH]。一旦您从输出中提取了网络对象,您只需要通过管道(fwdProp |> backProp)传递它,您就有了新的权重集。
在下一部分中,我们将利用这些和一些更多的“助手”函数,以便从一个非平凡函数生成训练数据,并使网络实际做一些实际工作。所以请继续关注,如果你喜欢这篇文章,请留下👏!
正如我所承诺的,下面是第 2 部分的链接:
你的神经网络训练够快吗?
towardsdatascience.com](/building-neural-networks-in-f-part-2-training-evaluation-5e3a68889da6)
用 F#构建神经网络—第二部分
Well, you’ll need F# for this so better read on!
快速回顾一下
欢迎来到本系列的第二部分。在第一部分中,我经历了执行单一(随机)梯度更新所需的步骤。在这一部分中,我们扩展了这一功能,使网络能够从数据中学习任意关系。在我们开始之前,请确保您已经阅读并实现了我上一篇文章中的代码:
函数式编码神经网络优雅吗?
towardsdatascience.com](/building-neural-networks-in-f-part-1-a2832ae972e6)
通用逼近定理
第一个在 1989 年被证明,神经网络可以被归类为通用函数逼近器。但是,这样的近似器是什么?
给定任意一个连续的 N 维函数 f(x) ,一个只有一个隐层和有限个神经元的神经网络有能力在固定的范围 x 和有限的误差 ε内逼近这样一个函数。
我们将利用这个定理,在多个非平凡函数上评估一个简单的架构,以证明我们的网络具有从数据中学习的能力。
一些助手功能
让我们定义这些额外的辅助函数,它们将帮助我们进行培训和评估:
genDataSet1D—仅使用开始、结束和步进信息,该函数生成向量元组的数组,每个元组对应于一个(x, y)对。注意,这可以是任何必要的维度,但是为了简单起见,本教程中的输入和输出都是一维的。yRealFn—这是我们定义目标功能的地方。来自该函数的数据点将被采样并绘制在最终图上,以显示我们试图建模的实际底层函数。yRealFnAndNoise—该功能只是在yRealFn的输出中增加一个概率项,模拟数据中存在的噪声。然而,如果该误差没有适当调整,与确定性项(yRealFn)相比,它将过大,导致网络试图学习噪声而不是实际函数。meanFeatureSqErr&evalAngEpochErr—这些功能将在本教程的培训部分解释。
初始化网络
定义架构
Sometimes, a single hidden layer is all we need…
为了测试我们的网络,一个只有一个隐藏层的简单神经网络就足够了。我研究了学习任何合理的平滑函数所需的隐藏节点的数量,发现 8 个节点就足够了。
根据经验,选择太少的隐藏节点会导致欠拟合,反之太多会导致过拟合。
当谈到选择激活功能时,手边有许多流行的: tanh , sigmoid , ReLU 等。我决定用 tanh ,因为它非常适合学习平滑函数中存在的非线性,比如我们测试中会用到的那些。
A plot of tanh(x) (red) and its derivative (green) [source].
选择超参数
如果你一直在跟踪机器学习,甚至从一开始你就学会欣赏超参数搜索或多或少的试错。当然,你也可以查看几个时期的成本函数图,做出你认为的最佳价值的有根据的猜测。
训练次数为~ 3000 时,学习率为 0.03 通常会给出足够好的结果。由于 F#中惊人的快速训练时间,我只能处理这么多的纪元!
目标函数
在我们开始训练之前,还有最后一步:选择合理复杂的函数,这些函数可以用来生成精确的数据集,同时在视觉上具有可比性。
您会注意到,对于位于相同范围内的所有输入,输出都受到闭合区间[0,1]的限制。这是一个深思熟虑的决定,以避免使用均值归一化,这有助于通过将所有特征保持在相似的范围内来防止过度加权。
A definition of our three test functions along with their plots
我们将使用步长为 0.01 的 x ∈ [0,1]来训练我们的网络。因此,在此范围内将产生 101 个等距样本,我们将保留其中的约 6%进行验证(统一选择)。
培训我们的网络
If only he was taught about under-fitting…
概述
让我们首先从顶层收集一下培训需要哪些功能:
- 对于每个时期,我们需要执行梯度下降,在我们的例子中,将随机地进行*(每个样本一次)。然后,我们将使用更新后的网络在下一个时期进行进一步的训练。*
- 在此过程中,通过在每个时期结束时评估我们的网络并获得误差度量(例如,平方误差)来跟踪我们的训练误差将是有帮助的。
使用地图折叠
保持接近函数范式,而不是使用传统的 for 循环,我们将使用
List.mapFold for training开发一个巧妙的技巧。
那么地图折叠是如何工作的呢?
简单来说就是List.fold和List.map的高效结合。它允许在给定当前列表元素和先前状态的情况下计算新的状态,同时使用我们选择的函数转换当前列表元素。该函数的最终输出是一个由转换列表和最终状态组成的元组。
履行
在trainEpoch函数中,训练数组首先被随机打乱(就地)。这个新洗牌后的数组被送入perfGradDesc。该函数通过使用Array.fold传播网络更新,在整个训练数据集上一次执行一个样本的梯度下降(单遍)。
一旦我们训练了一遍网络,并从trainEpoch获得了最终网络,我们需要使用我们选择的成本函数来评估该时期的训练误差。每个样本的误差需要与lastLayerDeriv功能一致。因此,平方误差是由meanFeatureSqErr函数实现的误差度量的最合适选择。
注意:虽然我们的网络可以支持任何维度的输出,但当涉及到成本函数时,每个样本只有一个标量度量是有用的。因此,每个样本的误差将是每个输出特征的平方误差的平均值。这不会影响一维输出的误差,如果所有输出维度的比例大致相同,这是一个合理的选择。**
在对整个训练集的每个样本的误差进行平均后,我们获得了均方误差。该值由evalAvgEpochErr功能计算得出。
最后,在每个时期结束时,(xAndyShuffArr,newNet)是传播到下一个时期的新的状态*,而err是替换原始列表中时期号的映射对象。*
The code for training our network
结果
The results for y = 0.4x² + 0.2 + 0.3xsin8x, spanning multiple epochs.*
所以这里有一些视觉证明,网络实际上是在训练!为了绘制我们的数据,我们将使用我过去在 F#中用来绘制的PLplot库。绘制这些图形还需要一些额外的代码,但是可以在 Github 资源库中找到,这个资源库在文章的结尾有链接。
神话;传奇
- 粉色 线代表底层真函数无噪声。这仅用于绘图,但从不用于任何计算。
- 青色 点是整个数据集,包括训练和测试数据点。所有误差都是参照这些点计算的
- 绿色圆圈代表在训练 数据上评估的最终假设。随着训练时期数量的增加,您可以观察到这些曲线越来越接近真实的函数曲线**
- 最后,粉色 十字代表基于测试数据评估的最终假设。如果你仔细观察,你会注意到他们很好地跟踪了训练数据点,并且这被训练与测试的 MSE 分数所证实
评估目标函数
From left to right: f(x) = 0.4x² + 0.2 + 0.3xsin8x, g(x) = 0.6e^-(0.3sin12x/(0.3+x)), h(x) = 0.4 - 0.1log(x+0.1) + 0.5xcos12x
这是我们三个测试函数的结果,你可以点击每一个来查看更多细节。只需快速浏览一下,很明显,网络通常在更平滑的函数上表现良好。随着每个函数的驻点附近的梯度增加,神经网络的训练也变得更加困难(观察损失曲线)。这可以与我们使用 tanh(x)作为激活函数的事实联系起来。
尽管如此,我们现在有了直观的证据,证明了普适近似定理是成立的!
任务已完成💪🏼
结论
现在,我们已经定义了基本架构,并且能够对任意数据进行训练,我们可以使用网络来训练和测试流行的数据集。这是留给你的任务…为什么不试试著名的波士顿房价数据集?
这段代码在机器学习的世界里还有很长的路要走。它们是许多扩展,甚至可以对最简单的神经网络进行扩展以改善收敛性:批量标准化、小批量梯度下降、自定义权重初始化等等。
嗯,不要担心,只是在 F#上训练 3000 个历元需要大约 3 秒的事实使它成为快速神经网络修补和评估的良好潜在候选对象。使用非常相似的设计原则和相似的参数选择,这个脚本的 python 版本需要大约 168 秒(在 Google Collab 上)!
正如承诺的,所有的源代码都可以在Github上获得。
我希望你能从过去的两个教程中获得一些新的东西,它们是一个漫长的过程,但我在这个过程中学到了很多。一如既往,请随时分享您的建议,反馈和对我的下一个教程的任何建议。如果你喜欢这篇文章,请留下👏🏼或者两个…下次见!👋🏼
用 Python 构建机器学习项目包
我见过的大多数机器学习项目都处于永久的实验状态,从技术角度来看没有明确的成功计划。创建一个算法,实际上解决一个可行的问题,做好它本身是困难的。然而,以可靠的方式管理高度实验性的代码库并使您的项目准备好部署是另一个难题。尤其是如果你没有开发人员的背景。
We want to make rocket science in our projects. But we need to make sure our rocket will not explode 5 seconds after launch. “white space shuttle indoors” by SpaceX on Unsplash
在我的下一个系列中,我将涉及一些我认为有用的主题,以便更容易地从实验过渡到生产。其中大部分还有助于保持代码的可靠性和结果的可重现性。
以下是清单:
- 构建 python 包
- 为其创建命令行界面
- 管理依赖关系(python 和非 python)
- 定义依赖图
- 创建自动化(单元)测试
- 将其作为 rest api
- 归档
今天,我将介绍如何创建 python 包和命令行界面。同样在开头,我将展示涉及第三和第四个要点的另外两个关键原则。
实验代码
我们从非常简单肮脏的“原型”开始。这甚至不是一个应用程序,只是一堆脚本。
Our “app” at time zero
我们有空的 readme、github 生成的许可文件和 gitignore、一些 bash 脚本和三个 python 文件。因为数据是数据科学家最宝贵的资源。
数据文件通常太大,无法存储在代码库中,需要托管在其他地方。在这个应用中,我们使用公共的 aclImdb_v1 数据集进行情感分析。
第一条规则永远要确保你能再次获得数据——并且你的团队知道如何去做。
我们可怜的原型在这方面已经很不错了。让我们来看看download_data.sh
set -emkdir -p data/rawwget [http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz](http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz) -O data/raw/imdb.tar.gztar -xzf data/raw/imdb.tar.gz --directory data/rawrm data/raw/imdb.tar.gz
该脚本创建目录data/raw,下载数据集,解包并删除不再需要的归档。理想情况下,我更愿意将数据保存在我的 S3 账户中(以防链接过期),但我们可以假设这已经足够好了。然而,实际上你的数据可能是一个数据库转储,一堆需要合并的 excel 文件等等。您应该为所有数据采集步骤创建脚本(例如:可以编写数据库转储脚本),以确保能够以自动化方式运行整个过程。
有些情况下是不可能的(因为各种原因:技术、法律、合规等)。在这种情况下,所需的步骤必须明确记录。新的团队成员应该能够获得数据,而不需要任何人的帮助。但是记住,最好的文档是可执行的文档;)
现在,让我们来看看 requirements.txt
numpy
pandas
scikit-learn
所需软件包的列表非常简单,但目前还不错。跟踪所有项目的依赖关系是非常重要的。稍后我们将会看到仅仅列出所需的包是不够的,但是如果你甚至没有使用这个简单的列表,请开始吧。
规则#2 列出你所有的依赖项,并创建单独的环境
为每个项目创建单独的 conda/虚拟环境,并列出所有依赖关系。如果您的项目需要一些额外的非 python 依赖项(如数据库驱动程序、系统范围的包),请明确列出它们,或者如果可能的话,为它们创建安装脚本。请记住,手动步骤的数量必须尽可能少。这也适用于“一次性活动”。我有时很惊讶那些一次性被执行的频率。
现在我们可以继续学习 python 代码了。
文件dataset.py包含简单的类数据集,用于读取数据集(惊喜)。代码很好,除了硬编码的路径(我们稍后会解决)
下一个文件,train_model.py是脚本文件中包含的一串语句。该脚本做了应该做的事情,但我们需要对它进行修改。初始版本的内容如下:
步骤非常简单,模型被训练和存储到文件以及使用矢量器。随后,根据测试数据对其进行评估,并打印出一份简单的报告。该脚本可以工作,但是正如您可能认为的那样,它与良好的实践没有任何共同之处。不幸的是,很多“只是实验”的代码并没有退出“代码质量”这个阶段。我们将打破这一障碍;)
所使用的模型非常简单,其准确性很差——但这不是问题。我让事情尽可能简单,因为这不是关于创建一个好的分类器的系列;)在现实中,这样的训练脚本更复杂,但在更简单的例子上更容易解释事情。
为了示例的完整性,还有一个脚本— ask.py。它的作用是提供一个对新数据进行预测的接口。
我们走过了“数据科学实验”的初始状态。现在,我们将把它转变成生产就绪的解决方案。
创建 Python 包
第一步是创建一个包。有许多基本上没有成本的好处。其中一些:
- 清除项目结构
- 能够为部署创建源代码/二进制分发
- 版本控制
- 自动相关性检查
- 轻松创建扩展
- 分离(例如,您可能不想包含预测环境的训练代码)
- 还有更多:)
规则 3 打包你的代码
创建一个包很容易。首先我们需要选择名字;)姑且称之为 mlp(机器学习-生产)。创建这样的目录,并将源文件移动到其中:
mkdir mlp
mv dataset.py train_model.py ask.py mlp/
接下来,我们需要做一些轻微的调整。包代码不应该是直接可执行的。因此,它必须包含在类或函数中。顺便说一下,我们将把硬编码的路径提取到参数中。对于数据集,我们只需要修改类构造函数:
另外两个文件共享相同的模型。这意味着,这部分应该被提取。大概是这样的:
数据集和模型是我们能够提取为可重用组件的两个部分。剩下的两个(ask 和培训脚本)更像是启动器。现在,让我们删除它们。我们将创造一个更好的版本;)
包装还有两件事要做。首先是在 mlp 目录中创建__init__.py,内容如下:
from mlp.dataset import Dataset
from mlp.model import DumbModel
__init__.py是在加载模块的瞬间执行的(你可以把它想成“包构造器”)。它不应该做太多的事情,但是导入被认为是包的公共部分的部分是一个非常常见的场景。这不是必需的,但允许我们写:
from mlp import Dataset, DumbModel
代替
from mlp.dataset import Dataset
from mlp.model import DumbModel
最后一件事是创建setup.py in项目根目录。这个文件包含了关于这个包的所有元信息。它可能非常大,但是对于我们的目的(此时)几行就足够了:
我们只需要定义名称(这是您在 pip install XXXX 中键入的内容)、包列表(这是您在 import 语句中使用的内容)和版本—参见 PEP 440 。
该项目目前看起来是这样的:
最后要做的事情是安装我们的软件包。要执行此操作,请键入以下内容(在项目根目录中):
pip install -e .
没有魔法:我们提供了一个点来代替包名,意思是“安装当前目录”。-e开关使其安装在开发模式下。这基本上意味着软件包源代码中的每一个改变都会立即反映在已安装的版本中——在做出改变后就不需要重新安装了。您可以在执行 pip 列表后看到您的包:
$ pip list
Package Version Location
--------------- ---------- -----------------------------
cloudpickle 0.6.1
cycler 0.10.0
...
mlp 0.0.1.dev1 $HOME/blog/mlp
....
为了确保一切正常,您可以执行 python repl 并尝试实例化数据集和模型。
$ python
>>> from mlp import Dataset, DumbModel
>>> d = Dataset()
>>> m = DumbModel()
命令行界面
我们到达了中点。今天的第二个主题是创建一个界面。最初,有两个肮脏的脚本。相反,我将向您展示如何创建一个漂亮的命令行界面。
为你的项目定义好界面。一堆脚本是不够的。
将有两个命令。一个用于训练模型,第二个用于请求预测。让我们从在项目根目录下创建名为mlp.py的文件开始。这将是我们的“发射器”。我们的命令非常简单,只需为它们创建两个简单的函数:
当然,当这样的函数很长时,将它包含在包中总是一个好主意,只需从启动器中调用它。
好吧,但是我们怎么调用它们呢?类似这样的东西可能有用:
python mlp.py train data/raw/aclImdb/ model.pickle --vocab-size=5000
或者用于预测:
python mlp.py ask model.pickle "This movie is awesome"
如何创建?有几种可能性,例如:argparse 或 click。但是我准备用 docopt 。安装它,不要忘记将它包含在 requirements.txt 中
使用 docopt,我们通过编写帮助信息来定义界面。可能看起来很疯狂;)
在mlp.py的开头添加以下内容:
通过阅读这个 docstring,您已经知道了哪些命令是可用的,需要哪些参数等等。Docopt 将对此进行解析,并为我们创建参数解析器。我鼓励你看一下 docopt 的文档以获得更多信息。
执行我们的命令非常简单。在mlp.py的末尾增加以下内容:
我们只是检查选择了哪个命令,并传递它的参数。就这么简单。
可安装命令
今天我们只做一件小事。目前我们的发射器在包装之外。让我们更紧密地整合它。将我们的脚本移动到 mlp 目录,并将其重命名为 cli.py(命令行界面)。将 docopt 帮助消息与以下内容稍微对齐:
我们将由mlp-cli指挥执行我们的项目。我选择不使用普通 mlp,因为它会与 mlp 目录冲突。
下一次更新setup.py为:
我们在包中添加了一个入口点。它是一个控制台脚本,可执行名为mlp-cli。该可执行文件将从包mlp.cmd中启动 main 函数
当我们编辑安装脚本时,需要重新安装软件包:
pip install -e .
记住:当你改变包代码时,没有必要重新安装它(只要你使用了-e 开关)。如果您更改 setup.py 内容,您必须重新安装软件包。
现在我们可以在终端中键入mlp-cli -h来验证它是否工作;)
是的,我们有一个单一的入口来与我们的项目沟通;)再也没有乱七八糟的剧本了!
摘要
那是相当多的。我们学习了如何创建一个基本的 python 包,并为它创建一个命令行界面。此外,我们设法使它与 it 完全集成。不要忘记关于再现性的两条基本规则。现在项目结构应该是这样的:
这时你可以从项目库中下载代码;github.com/artofai/mlp。寻找标签part1-finished。
在下一篇文章中,我将解决引言中列出的另一个问题。
锻炼
如果你愿意,你可以做一个简单的练习:在我们的应用程序中添加一个期权限制。数据集已经准备好处理这样的参数;)
敬请期待,后会有期:)
用 Python 构建预测 API(第 1 部分):系列介绍&基本示例
好吧,你已经训练了一个模型,但是现在呢?如果没有人会使用,所有的工作都是没有意义的。在某些应用程序中,您可以简单地创建一个批处理作业来收集需要评分的记录、准备这些记录并运行模型。然而,其他应用程序需要或者至少高度受益于用于实时评分模型的过程。理想情况下,我们甚至希望通过 REST API 与他人共享使用模型的能力。虽然部署一个简单的模型相当容易,但是在这个起点上扩展和迭代是有挑战的。
在本系列中,我们将采用增量方法来构建预测 API。在这篇文章中,我们将构建尽可能简单的 API,允许我们对模型进行评分并返回预测。随后的每篇文章都将关注我们如何在已经实现的基础上进行改进。这个系列不会有固定的结局。随着新主题的出现,我会尝试扩展已经完成的内容——这取决于我有多少时间和读者的兴趣水平。
目标受众
在整个系列中,我们将主要关注构建模型评分平台的软件工程方面,并讨论工程和数据科学之间的灰色地带。我将在很大程度上假设读者有一个他们想要部署的模型,或者他们有能力生成这样一个模型。因此,我们通常不会讨论构建预测模型的过程。有很多很棒的教程和书籍涉及这个主题。
这个系列有两个主要的受众。第一类是全栈数据科学家,他们希望通过 API 部署他们的模型,但不确定如何有效地完成。第二类是没有在数据科学领域工作过的软件工程师或技术产品经理,他们可能会从理解模型部署与一般软件部署的不同中受益。
工具
我们将使用 Python 3 作为主要语言。特别是,我将主要使用 Python 3.6。烧瓶将用于初始原料药。所有模型都将使用 Scikit-Learn 构建。如果您不确定如何用这些包来设置开发环境,我推荐从 Anaconda 开始,因为它提供了您开始设置所需的一切。
虽然这个工具集的范围相当窄,但是我希望这些主题可以广泛地应用于其他编程语言和 API 框架。
基本示例
既然我们已经为本系列奠定了基础,那么让我们构建一个基本模型。我们将从建立在 iris 数据集上的随机森林分类器开始。
同样,我将跳过模型构建过程的讨论,所以我们不打算深入研究这个例子。要注意的主要事情是,一旦我们建立了模型,我们就使用joblib将它保存(pickle)到一个文件中。虽然这种方法可行,但是这种方法存在一些问题和限制。Scikit 有一篇关于这个的很棒的文章,但是主要问题是:
- 安全性:当您
load一个腌泡对象时,您实际上是在执行存储在文件中的代码。仅加载您信任的持久化对象。 - 可移植性:在构建环境和生产评分环境之间 scikit-learn 版本的任何变化(或潜在的依赖性)都会导致最低限度的警告和潜在的意外行为,例如失败或预测错误。
我们暂时将这些问题放在一边,但这些可能是我们在后续文章中探讨的主题。
构建我们的第一个 API
现在,我们将使用 Flask 构建尽可能简单的 API。我们将包含一个端点/predict,它将允许我们通过查询参数传递特征值。
我们在全球加载我们的模型为MODEL。我们还有一个标签列表(MODEL_LABELS),对应于MODEL.predict()将输出的整数值。我们使用@app.route('/predict')装饰器创建了 API 端点,并定义了predict()函数来处理发送到该端点的请求。对这个端点的调用应该包含 4 个参数,它们对应于我们的特性:sepal_length、sepal_width、petal_length和petal_width。例如,一个呼叫看起来像这样:
[http://127.0.0.1:5000/predict?sepal_length=5&sepal_width=3.1&petal_length=2.5&petal_width=1.2](http://localhost:5000/score?sepal_length=5&sepal_width=3.1&petal_length=2.5&petal_width=1.2.)
Flask 处理 URL 查询字符串的解析,并将参数添加到request.args中,它有一个类似于 python dict的 API。如果键存在,使用get将检索一个值,否则将返回一个默认值。因为我们没有指定默认值,所以将返回None。此外,默认情况下,args中的值将是字符串,但在评分过程中,模型会自动将这些转换为浮点数。
特征值然后被打包到一个嵌套列表(列表的列表)中。这是必要的,因为我们的模型需要一个记录列表,其中每个记录都是特性集的长度。我们一次只对一个记录进行评分,这就是为什么外部列表只包含一个内部列表(记录)的原因。
我们使用MODEL.predict()来获得预测的类,它将是 0、1 或 2。最后,我们可以通过使用类作为进入MODEL_LABELS的索引来获得标签。
运行我们的预测服务
现在,我们可以从命令行启动我们的 Flask 服务器。
在我们的浏览器中,我们可以使用上面的例子发出一个测试请求。
Test URL: http://127.0.0.1:5000/predict?sepal_length=5&sepal_width=3.1&petal_length=2.5&petal_width=1.2
我们也可以使用requests来做同样的事情。
前方是什么
在 20-30 行代码中,我们构建了一个简单的模型,并创建了一个接受请求并返回预测的 API。然而,有几个方面我们可以并且应该比我们在这里所做的做得更好。对这些主题的探索将在后面的文章中处理,但这里有一个快速预览:
- 错误处理:目前,我们在给模型评分时不做任何错误处理。当我们收到坏数据或数据丢失时会发生什么(没有提供
sepal_length)?如果失败了,打电话的人会收到什么样的反馈? - 自动化测试:我们的代码中有错误吗?除了手动运行几个请求,是否有可能自动化这个过程?
- 可扩展性:这个 API 只处理一个单一的全局模型。我们应该为我们的下一个模型创建另一个一次性的 API 吗?如果我们创建一个现有模型的新版本会怎么样?每个版本应该有一个独立的 API 吗?
- 数据收集:没有从这个 API 收集数据。我们如何跟踪我们的模型执行得有多好?我们知道什么时候会出错吗?处理一个请求需要多长时间?如果我们开始收集数据,我们应该把它存储在哪里?
- 部署:这是在本地运行的,但是我们需要把它放在服务器上。我们如何扩展?我们应该没有服务器吗?我们如何处理负载测试?
- 特性工程:这个 API 期望请求者已经准备好了任何特性,但是我们经常需要在数据准备好被模型使用之前对它们进行一些转换。我们应该如何以及在哪里做这件事?
在第 2 部分中,我们将看看 API 的基本错误处理,并涵盖定义何时应该使用模型的重要性。
[## 用 Python 构建预测 API(第 2 部分):基本错误处理
在最初的系列文章中,我们创建了一个简单的 API 来对建立在 iris 上的 Scikit-Learn 随机森林分类器进行评分…
medium.com](medium.com/@chris.mora…)
特别感谢 Geoff Svacha 为本系列提供反馈。
脚注
- 使用
datasets.load_iris().target_names可以得到这些标签。 - 类似于[1],使用
datasets.load_iris().feature_names并重新格式化这些。 - 我们将在本系列的第 2 部分对此进行更深入的探讨。
用 Python 构建预测 API(第 2 部分):基本错误处理
在初始系列文章中,我们创建了一个简单的 API 来对建立在 iris 数据集上的 Scikit-Learn 随机森林分类器进行评分。在本帖中,我们将探讨如何处理评分过程中出现的错误,以及定义何时应该使用模型这一更广泛的主题。
[## 用 Python 构建预测 API(第 1 部分):系列介绍&基本示例
好吧,你已经训练了一个模型,但是现在呢?如果没有人会使用,所有的工作都是没有意义的。在某些应用中…
medium.com](medium.com/@chris.mora…)
一个简短的激励话题
我们可以从测试一个简单的 Python 函数开始。没有模型,没有 API,只是一个将两个数相加的函数。
当我们考虑如何测试这个函数时,我们应该考虑所有可能出错的地方,并假设它会在某个时候出错。这个例子非常简单,我们只需要担心a和b的错误输入参数。以下是一些例子:
通过这一小组示例,我们可以看到基于“可接受的?”的三个不同类别专栏:商品、商品和不确定因素。“商品”是我们预期的输入,即我们可以加在一起的int或float值。“坏的”是引发异常的那些;Python 不知道如何添加的输入。Python 已经为我们抛出了一个异常,但是我们看到基于输入参数抛出的异常类型(TypeError或ValueError)有些不一致。我们可以捕捉这些异常并定制一个一致的异常,或者我们可以返回None。
最后一类,即“不确定性”,可能是最有趣的。这些产生了有效的结果,但是它们没有反映我们最初打算支持的内容。在这里,我们可以选择如何解决我们的意图和实现之间的不一致。在 Python 中,我们通常不想强制类型,所以我们可以让代码保持原样。我们应该在 docstring 中或通过类型提示来表示预期的类型,以便用户理解我们正式支持什么,以防将来的变化破坏当前的功能。但是,可能有些情况下,我们希望拒绝返回值,除非满足某些条件。在这些情况下,我们可以显式地检查输入的类型,甚至将它们限制在某个可接受的值范围内(例如,只添加小整数)。
模型特征的错误
既然我们已经看了一个简单的例子,让我们回到第一部分的原始预测 API:
从我们上一节介绍的内容开始,我们将考虑通过request.args传递的错误输入参数。对于其中的每一个,我们都期待一个可以转换成float的值。然而,使用上面的方法,我们将获得每个的字符串值。Flask 通过向request.args.get()方法提供type参数,使得转换查询参数变得容易:
request.args.get('sepal_length', default=None, type=float)
这将尝试将sepal_length的值转换为float,如果转换失败,将返回由default关键字参数指定的默认值。注意: default 关键字参数的缺省值是 None ,所以我们可以省去这个参数。
除了原始版本(没有指定type),我们还处理请求中缺少查询参数的情况。在这两个版本中,相应的变量将被赋予默认值None。
我们可以以类似的方式更改检索sepal_width、pedal_length和pedal_width的request.args.get()调用。然而,当我们重启服务器并测试一个带有错误值或丢失参数的请求时,我们会遇到另一个问题:我们的模型不能处理丢失的值。提出这样的请求会导致ValueError: Input contains NaN, infinity or a value too large for dtype('float32')。
我们应该得分多少?
使用我们的adder函数,来自“不确定”类别的输入值会产生一个结果(没有引发异常),但也许不应该。在我们的模型评分中,我们发现了一个以相反方式处理的类似组。我们当前的实现是拒绝缺少值的请求(引发一个ValueError),但是也许我们应该找到一种方法返回一个预测。
在任何模型部署中,确定哪些请求应该被评分,哪些应该导致错误或警告是一个关键的考虑因素。这通常是一个微妙的决策,涉及模型开发人员、业务合作伙伴和产品所有者,并寻求平衡预测的好处和错误的代价。作为一个利益相关者,我有意忽略了软件开发的角色。这是因为如果有足够的时间和资源,他们通常能够实现预期的意图。从长远来看,模型的使用应该由数据科学、业务和产品需求驱动,而不是由评分平台实现的便利性驱动。虽然我已经分别列出了这些角色,但是同一个人可能会满足他们中的许多人或所有人——这使得我们在做出这些决定时采取哪种观点更加重要。
这里有几个场景来帮助说明为什么我们需要慎重决定对哪些请求进行评分:
- 在线广告:假设我们已经建立了一个模型来预测在一组可能的广告中,我们应该向我们网站的特定访问者显示哪个广告。通过用户跟踪,我们知道大多数访问者的参考页面或网站,但对于一些人来说,这是缺失的。在这种情况下,做出次优甚至差的预测可能是好的,因为错误的成本很低。一个担忧可能是次优预测会如何影响我们对广告进行的任何 A/B 测试——特别是如果这些低质量的预测偏向某个特定的广告——但访问者不太可能因为看到了“错误”的广告而逃离我们的网站。
- 扩大贷款/信用:高成本决策的一个例子是信用贷款;我们的模型将预测某人的信用度或风险。贷款机构通常会将模型的许多关键特征建立在信用局数据的基础上。例如,如果客户正在申请贷款,我们想知道他们是否在另一笔贷款上违约,或者在最近几年申请破产。信用局的数据对贷款决策至关重要,如果没有这些数据,我们不会对客户的申请进行评分。
- 欺诈检测:创建欺诈防御通常是阻止可疑活动和激怒好客户之间的平衡行为。虽然与合法使用相比,欺诈在事件发生率上通常很少,但它会给公司带来巨大的财务和声誉成本。本能反应可能是积极使用任何可以减少损失的欺诈模式。然而,欺诈也是对抗性的,欺诈者会调整他们的方法来规避现有的防御。由于这些原因,对欺诈行为进行强大而一致的防御通常非常具有挑战性。此外,标记为欺诈的事件可能会导致交易被拒绝、帐户被限制或申请被拒绝。这些处理方法在正确应用时非常有效,但假阳性会导致糟糕的客户体验,并可能导致有价值客户的参与度降低或流失。由于这些因素,基于预测模型的决策可能仅适用于可疑事件总数中的一小部分。
缺失和不良输入的不同处理方式
现在,让我们回到我们的 API 示例来展示我们如何实现这些模型使用策略。
拒绝请求
我们可以从拒绝对任何有坏值或缺失值的请求评分开始。最简单的方法是捕捉评分过程中引发的异常。
这里,我们在MODEL.predict()周围添加了一个try/except块,这样我们就可以捕捉到任何引发的异常。大部分代码只是准备一个错误响应。现在,如果用错误的或缺失的值调用 API,调用者将会收到这样的消息:未能对模型进行评分。异常:输入包含 NaN、infinity 或对 dtype 来说太大的值(“float32”)。
我们还使用适当的 HTTP 状态代码来为我们的用户提供额外的反馈,这不仅是一个阻止我们对请求评分的错误,而且是他们的错误。这是通过在对“400 错误请求”的响应上设置status_code来完成的。在我们之前版本的 API 中,当出现未处理的异常时,Flask 仍然会为我们发送响应,但status_code是“500 内部服务器错误”。虽然这提供了支持,但是“500”错误仅仅意味着 API 服务器端出了问题。它不会通知呼叫者他们的请求是原因,所以他们可能会尝试重复呼叫。请注意,当我们返回一个成功处理的请求时,我们不需要设置status_code,因为该响应将自动具有“200 OK”的status_code。
虽然这种方法是成功的,并且这个错误消息是相关的,但是这不是处理输入错误的理想方法。我们正在捕捉一个基本异常。虽然我们没有遇到任何其他异常,但这并不意味着它们不会发生。我们应该努力将输入错误与其他错误隔离开来。此外,我们正在发回异常的内容。如果此错误消息中包含敏感信息,我们会将其发送给呼叫者。这可能是一个安全问题。
直白
一个改进可能是显式地捕捉最初测试中出现的ValueError异常。但是,不清楚这是否是值错误或丢失时引发的唯一异常。此外,ValueError也可能发生在与调用者提供的错误/丢失数据无关的其他地方。由于这些因素,最好的方法是显式地检查和处理输入值。
我们添加了if块来检查我们的任何特征值是否为None。如果请求中缺少它们或者它们的值不能被转换成float,就会出现这种情况。
有几件事可以改进:
- 我们只解决缺失的功能或那些不能转换成
float的功能。在实际场景中,我们还想识别超出可接受范围的值。例如,所有四个特征都是花瓣和萼片长度的测量值,因此它们必须是正数。这些测量值也有一个上限。一万厘米的花瓣是不可能的。 - 我们应该跟踪一个请求是被成功评分还是被拒绝。这可以使用 Python 的
logging模块来完成,但这是我们将在另一篇文章中更深入探讨的内容。
让我们给每样东西打分
我们用来建立模型的虹膜数据集已经非常干净了。在大多数真实场景中,这种清理和准备过程是模型构建的一大部分。特别是,我们在构建数据集中没有遇到任何缺失值。这是一个很大的话题,超出了本文的范围。你可以通过搜索“处理机器学习中的缺失数据”或“插补机器学习”找到大量的优秀资源。
这里我们将使用一种简单的方法来处理缺失值:使用特征的平均值。这叫做均值插补。
我们可以通过使用:X_train.mean(axis=0)获得每个特征的平均值。查看原帖看看我们的训练集是如何构建的。这可能会根据构建训练数据集时使用的random_state而有所不同,但它应该与以下内容非常相似(为清晰起见,四舍五入):
实现这一点就像在我们获得请求参数时更改默认值一样简单:
我们可以测试这个迭代,并验证它是否正常工作。事实上,我们可以看到,即使没有提供参数,它也会对请求进行评分。
在灰色地带得分
我们将跳过一个微妙情况的实现,在这种情况下,我们拒绝缺少重要特征的请求,但如果其他特征缺失,我们将使用均值插补。这可以通过将上述技术与一些定制逻辑仔细混合来实现。作为练习,您可以实现一个 API,该 API 将只对同时满足这两个条件的请求进行评分:
sepal_length必须有效。- 其余 3 个特征中只能有一个缺失。
后续步骤
关于这些实现,我们有几个地方需要改进。首先,我们将模型和 API 耦合得比最初的例子更紧密。除了检索特定的查询参数,我们现在检查它们的值是否是None或者用特定的值来输入它们。比方说,我们需要根据新数据重新调整模型。对于均值插补,我们需要检查并更新 API 代码库中每个特征的默认值。最终,我们希望构建一个 API,它可以对多个模型进行评分,包括具有不同特性的模型。理想情况下,我们希望模型本身检索它们需要的特性,准备它们,如果它们不应该获得记录,可能会引发异常或返回错误。然后,API 将只负责向模型传递数据,并根据模型预测的结果准备响应——这是一种明确的职责分离。
第二个改进是存储与发生的请求和错误相关的数据。如果我们有不能得分的请求,我们至少想知道这种情况多长时间发生一次,也许可以将这些拒绝分类。因为我们没有保存任何与我们正在处理的请求相关的数据,所以我们没有办法回答这些问题。
虽然这些都很重要,但在下一篇文章中,我们将重点关注如何测试我们的 API 对请求评分的能力。
[## 用 Python 构建预测 API(第 3 部分):自动化测试
在上一篇文章中,我们改进了预测 API 的错误处理,并考虑了关于哪个…
towardsdatascience.com](/building-prediction-apis-in-python-part-3-automated-testing-a7cfa1fa7e9d)
脚注
- 返回
None而不是引发异常在函数式编程中很有帮助。例如,如果我们正在使用df.apply(my_funct, axis=1)计算熊猫数据帧的新列,并且我们预计某些行的计算会失败,我们可以让my_funct返回None或numpy.nan。结果列将包含计算成功的正确值,并将失败的计算视为缺失。这类似于也许单子模式。 - 我们收到这个错误是因为我们的模型会自动将我们的嵌套列表转换成包含数据类型为
float32的元素的numpy.ndarray。在此转换过程中,None元素将变成numpy.nan。
用 Python 构建预测 API(第 3 部分):自动化测试
在上一篇文章中,我们改进了预测 API 的错误处理,并考虑了我们应该对哪些记录进行评分的微妙决定。在这篇文章中,我们将看看如何使用 pytest 测试我们的 API。
[## 用 Python 构建预测 API(第 1 部分):系列介绍&基本示例
好吧,你已经训练了一个模型,但是现在呢?如果没有人会使用,所有的工作都是没有意义的。在某些应用中…
medium.com](medium.com/@chris.mora…)
和往常一样,我们将使用 Python 3,并且我将假设您要么正在使用 Anaconda,要么已经设置了一个安装了这些包的环境:flask、scikit-learn 和 pytest。
注意:我将在这篇文章中浏览代码片段,但是在上下文中查看完整的文件可能会有所帮助。你可以在 GitHub 上找到 的完整例子。
测试案例
虽然自动化测试是现代软件开发中的核心实践,但它尚未被许多数据科学家完全接受。简单地说,这是在主代码基础上运行测试的附加代码。测试框架,比如 pytest,使得定义和执行一套测试变得很容易。随着新功能的实现或现有代码的重构,这些测试帮助开发人员确认现有功能没有被破坏或定位已经引入的错误。
以下是一些不使用我的(尖刻的)回答编写测试的常见借口:
- *代码很少,所以不需要测试。*当您需要扩展当前功能时会发生什么?每个增强可能都很小,但是从长远来看,您可能会得到一个庞大的未经测试的代码库。如果目前代码很少,编写测试应该很容易,那么就去做吧!
- 当我写原始代码时,我已经根据多个测试用例检查了每个功能。太好了!构建测试的一个挑战是提出好的测试用例,而你已经做到了。每当你做一个小的改变时,有能力运行所有那些测试用例不是很棒吗?
- 我写代码不出错。当然啦!你不需要测试用例来测试你的代码,但是当其他人加入到项目中并开始修改你漂亮、完美的代码时会发生什么呢?我们怎么知道他们是否打碎了什么东西?你愿意详细检查他们所做的每一项承诺吗?你对在你的余生中维护这个代码感到兴奋吗?
在进入这篇文章的内容之前,我想指出我将是一个伪君子。我们将专注于测试 API。在这个过程中,我们还需要修改构建模型的代码。敏锐的读者会注意到,我没有为模型构建管道编写任何测试。如果有帮助的话,我真的很抱歉。
为更好的测试进行调整
在我们开始编写实际的测试之前,我想改变一下我们正在生成的 API 响应。目前,我们只发送回预测的类(虹膜类型)。虽然这是我们的预测 API 的用户所需要的,但它并没有提供大量的信息供我们测试。预测类被选为具有最高模型分数的类(虹膜类型)。由于这种阈值处理,即使潜在的分数有些不同,预测的类别也可以是相同的。这类似于一个函数,它执行复杂而精确的计算,但返回一个舍入到最接近的整数的值。即使许多输入的返回整数值与预期值匹配,基础计算也可能不正确。理想情况下,我们希望在计算的精确结果被阈值化或舍入之前验证它们是否正确。
为了给我们自己提供更多的数据来验证我们对模型的评分是正确的,我们将改变 API 响应来包含每个类的概率。这是通过调用MODEL.predict_proba()而不是MODEL.predict()来完成的。然后我们使用argmax()来获得最大值的索引,这给了我们预测的类。我们将通过probabilities将原始类别概率返回给用户(参见下面的示例响应)。
Updated API to return class probabilities
然后我们可以运行 API ( python predict_api.py)并通过requests进行测试调用:
API 的基本测试
我们将从一个简单的例子开始,在这个例子中,我们只对如上所示的相同例子进行评分,但是我们将使用 pytest 来完成这个任务。我们首先需要创建一个测试文件:test_predict_api.py。现在,我们可以把它放在与我们的 API 文件相同的目录中:predict_api.py。注意:pytest 能够自动定位测试文件和函数,但是您需要协助它这样做。默认情况下,它将检查任何文件名以“test_”前缀开头的文件,并运行任何以“test_”开头的测试函数。
当我们构建测试时,我们通常遵循这种模式:
- 设置(可选):测试或环境的初始化。示例:初始化数据库,创建将在测试中使用的类的实例,初始化系统的状态,等等
- 运行代码:在预定义的测试用例上或在预定义的环境中运行主代码库(测试中的代码)中的一些代码。这可能包括:调用一个函数/方法,创建一个类的实例,初始化一个资源,调用一个 API 等等
- 验证结果:通过使用
assert语句检查代码的效果是否符合预期:函数调用的返回值是否正确,异常是否被适当地提出,系统是否已经改变到正确的状态等等… - 拆除(可选):测试运行后进行清理,将环境恢复到默认状态。
以下是本示例如何与这些步骤保持一致:
- 设置:实例化一个
test_client(如下所述),它将允许我们模拟对 API 的调用。 - 运行代码:用一组预定义的特性调用
/predict端点。 - 验证结果:我们得到一个带有 200 的
status_code的响应,内容是带有正确格式和值的 JSON。 - 拆:这个有些含蓄。我们将在
test_client的“设置”过程中使用上下文管理器。退出时,客户端将被清理。
如上所述,我们使用的test_client是 Flask 的一个特性,它允许我们在不运行服务器的情况下模拟调用。这里,我们使用上下文管理器创建一个新的客户机。由此,我们发出一个 GET 请求。query_string关键字参数提供的功能类似于params在requests.get()中的工作方式;它允许我们传递用于创建查询字符串的数据。
从响应中,我们检查我们是否收到了“200 OK”状态,最后我们检查响应的有效负载是否与我们预期的相匹配。既然它是以bytes的形式出现,我们可以用json.loads()把它转换成一个dict。
我们现在可以在命令行使用pytest来执行测试。
总是先失败
每当你编写自动化测试时,验证你确实在测试一些东西是很重要的——你的测试可能会失败。虽然这是一个显而易见的说法,但一个常见的缺陷是编写的测试实际上并不测试任何东西。当它们通过时,开发人员认为被测试的代码是正确的。然而,测试通过了,因为测试写得很差。
防止这种错误的一种方法是使用测试驱动开发(TDD)。我们不会对此进行深入探讨,但这是一个发展过程,其中:
- 您首先为一个新特性编写一个测试。
- 您确认测试失败。
- 您编写代码来实现新功能。
- 您验证测试现在通过了。
如果你之前没有尝试过 TDD,我绝对推荐。这需要纪律,尤其是在开始的时候,还需要其他开发者和利益相关者的支持。然而,在实现新特性时,对过程的投入会带来更少的错误和更低的压力。要了解更多,请阅读 Harry Percival 的优秀著作,用 Python 进行测试驱动的开发,这本书是他在网上免费提供的。
如果您不支持 TDD,我用来验证每个测试是否确实在测试的懒惰方法是将我的 assert 表达式更改为显式失败。例如,我们将把assert response.status_code == 200改为assert response.status_code != 200。如果您进行了此更改并重新运行测试,您应该会收到类似于以下内容的失败消息:
如果您打算使用这种方法,请注意 pytest 只会报告第一个发生的AssertionError。所以,你必须分别改变每个断言,然后重新测试。
更多测试
我们现在有一个对我们的 API 的测试调用,它正在工作。我们如何扩展这个来测试具有不同特征值和不同预期结果(标签和概率)的多个调用?一个快捷的选择是使用我们在模型构建期间创建的测试数据集。但是,我们需要获得类概率和预测标签,以用作每个输入记录的预期结果。
需要注意的一点是我们测试的是 API 平台,而不是模型本身。基本上,这意味着我们不关心模型是否做出了错误的预测;我们只想验证 API 平台上的模型输出是否与构建/离线/开发环境中的模型输出相匹配。我们还需要测试特性的准备(例如,均值插补)是否在 API 平台上正确完成,但我们将在本文的下一部分讨论这个问题。
由于我们的测试数据集可能会随着模型的每个新版本而改变,我们应该将这些数据的生成合并到我们的模型构建中。我对我们的模型构建脚本做了一些轻微的重构(还需要更多),并添加了在模型构建后生成测试数据集的代码。我们将把我们的测试用例存储在一个 JSON 文件中,每个测试用例的结构如下:
下面是我们的模型构建代码的修改版本,它合并了测试数据集的生成:
在顶部,有一个名为prep_test_cases()的函数,它只是将每个测试的特性、分类概率和预测标签重新格式化为我们的测试用例格式。
现在我们已经生成了测试数据,我们需要添加一个新的测试来对该文件中的所有记录进行评分,并检查响应:
因为我们以一种清晰的方式构建了测试数据,其中每个测试用例都有特性(API 输入)以及预期的响应(API 输出),所以测试代码相当简单。这种方法的一个缺点是类别概率是浮动的,我们正在对这些值进行精确的比较。通常,在比较浮点值时允许有一定的容差,这样非常接近的值就被认为是等价的。为了处理这个问题,我们需要解析预期的响应,并在比较probabilities中的每个值时使用pytest.approx()。它不需要太多的代码,但是我认为这会使这个讨论有点混乱,所以我省略了实现。
处理缺失值
我们的 API 配置为使用均值插补来替换错误值或缺失值,但我们的测试数据集不包含任何缺失值的记录。然而,这不是一个问题,因为我们可以使用我们已经拥有的数据来模拟这些数据。我们只需要用某个特性的平均值替换现有的值,并重新对记录进行评分。对于我们的模型构建脚本,我们将在原始测试数据生成代码之后添加以下内容:
我们可能不需要这么彻底,但是对于每一条记录,我们都在测试可能缺失的每一个特征组合。我们为每条记录创建了两个版本:一个有缺失值的None ,另一个有平均值的估算值。第一个将作为features存储在测试用例中(在None有价值的特性被删除之后)。第二个将被评分,以获得我们期望看到的 API 返回的预测概率。为了去掉 None值的特性,我们必须在prep_test_cases函数中对feat_dict的创建做一个小小的改变。下面是修改后的函数:
我们也需要改变我们的测试来使用这个新文件。虽然我们可以复制最后一个测试函数test_api()并替换文件名testdata_iris_v1.0.json,但这会导致重复的代码。因为我们需要测试函数除了文件名之外完全相同,所以更好的方法是使用 pytest 的parametrize功能。我们只需添加一个装饰器,允许我们为测试函数指定参数,并为每个值重新运行测试。在这种情况下,我们将传入文件名:
测试错误
在上一篇关于错误处理的文章中,我提到我们可以更好地选择我们愿意评分的记录,但是我没有提供这样的例子。这里我们将调整我们的 API 来看一个简单的例子,在这个例子中,我们将拒绝缺少petal_width数据的请求。我们将对所有其他记录进行评分,如果需要的话,使用平均插补。
稍微扯点题外话,我是怎么选的petal_width?嗯,如果我们查看特征重要性(使用model.feature_importances_,我们会看到第四个特征(petal_width)具有最高值,归一化得分为 0.51。因为这是我们模型中最重要的特性,所以拒绝缺少这个特性的记录是最有意义的。
实现这一点的一个简单方法是删除petal_width的默认值,然后在它丢失的情况下处理它。几乎所有的代码都保持不变,但是我在这里把它包括进来是为了便于理解。
我们可以做一个快速测试,以确保它适用于一个简单的情况:
太好了!有用!现在,我们只需要将它添加到我们的测试套件中。
为了简单起见,我将跳过如何修改我们旧的缺失值测试,只实现处理petal_width的缺失值或错误值的新测试。基本上,我从missing_grps中移除了所有包含 3 的元组(索引为petal_width)以及所有特性都缺失的测试。
对于我们的新测试,我们可以使用相同的 JSON 格式。这将是一个更干净的实现。为了清楚起见,我将在一个单独的函数中实现这些测试,这个函数有两个针对petal_width的测试用例:特性丢失和特性有一个错误的值(“垃圾”)。
我们可以重新运行我们的测试,并验证这些通过。当然,我们还应该尝试将==改为!=,以验证它们在每种情况下都失败。同样,这将有助于确保我们正在测试我们实际认为我们正在测试的东西。
识别问题
现在我们有了测试,我们可能想知道它们是否真的能捕捉到我们代码中的错误。也许,当您创建这些测试并在您的 API 代码上运行它们时,您已经发现了一些问题。如果没有,您可以做一些简单的更改,这些更改会导致一个或多个测试失败(分别进行这些操作):
- 在 API 代码中,将
sepal_length的default(平均插补)值从 5.8 更改为 5.3。 - 在 API 中,放回
petal_width的平均值插补。这将允许 API 对缺少petal_width的记录进行评分。当petal_width丢失时,您应该在期望 API 返回“400 错误请求”的测试中看到失败。 - 在 API 中,更改当
petal_width丢失时发送的错误消息的文本。 - 我们还可以模拟有人意外修改模型并试图部署它的情况。为了测试这一点,我们可以构建您的模型的替代版本,部署它,但是使用原始模型的测试数据文件(JSON)。实现这一点的快速方法是在训练/测试集分割中对
random_state使用不同的值(例如random_state=30)。记得把joblib.dump()中的模型输出文件名改成别的(例如*' iris-RF-altmodel . pkl '*);您需要在 API 中修改MODEL来引用这个文件。此外,确保您不要执行生成测试数据文件的代码,因为这些代码会基于替代模型重新构建它们。当您重新运行您的测试时,您可能会在所有测试中看到失败,除了那些当petal_width丢失或无效时拒绝请求的测试。如果您的测试仍然通过,尝试另一个random_state,因为模型可能是等价的,因为训练集可能保持不变或者变化不足以改变模型。
我们的测试肯定能发现问题,但是我们能发现所有的问题吗?简单的答案是,我们可能没有捕捉到一切。在创建这个帖子的时候,我试着将sepal_width的平均插补(默认值)从 3.0 改为 3.1。当我重新测试时,他们都通过了。也许这没什么大不了的;也许我们的模型对平均值附近sepal_width的微小变化并不敏感。这是最不重要的特征。然而,我们将我们的测试集用于测试用例,这些数据点不一定落在不同类的边界附近。如果我们有更多的测试用例或者更好的测试用例,我们可能已经能够捕捉到这种类型的 bug。
包扎
我们已经看到自动化测试可以帮助我们找到代码中的错误。虽然我们从测试单个 API 调用开始,但我们能够快速转向运行大量测试用例的框架,并且它只需要添加一点额外的代码。
测试是一个重要的话题,所以我们可能会在以后的文章中再次讨论这个话题。这里有一些我们没有涉及的领域的快速预览,但将来可能会涉及:
- 测试速度:当你改变代码时,经常运行测试是有益的。这使得在重构现有代码或添加新功能时更容易及早发现错误。如果测试需要一段时间来运行,开发人员就不太可能这样做。一种方法是将快速运行的测试与耗时较长的测试分开。然后,开发人员可以在进行增量更改时运行快速测试套件,并在将更改集成回主存储库之前运行完整套件。
- 嘲讽和修补:我们发现对
sepal_width的缺省值(平均插补值)的微小改变不会导致我们的测试失败。如果这是一个需求,我们可以在评分过程中使用补丁拦截对model.predict_proba()的调用,以验证正确的值被替换。 - Fixtures:这是 pytest 的一个特性,您可以创建、配置和销毁资源,以便建立一个干净且一致的环境,每个测试都可以在其中运行。如果你熟悉许多单元测试框架中的“安装”和“拆卸”, fixtures 就是这种思想的延伸。
- 子系统的集成:目前,我们只有模型和 API。在随后的文章中,我们将看看如何添加一个数据库后端和一些其他服务。我们如何测试这些?我们如何测试整个系统?
- 持续集成工具:这些工具有助于更容易地将代码集成到共享存储库中。通过使它变得更容易,我们希望它能更经常地、更小规模地完成。这些工具的一个共同特征是,它们会在每次提交拉请求时自动运行测试套件,并且通过/失败的结果会提供给评审者。
- 测试覆盖率:我们测试了我们代码的每一行吗?我们可以创建一个测试覆盖报告来帮助我们了解哪些代码行在测试期间运行了,哪些没有运行。这不会告诉我们是否已经处理了所有可能的情况,但是它可以给我们信息,让我们知道我们的测试套件在哪里不足。
- 高级测试方法:我们不太可能涵盖这些主题,但是我想提到它们。使用基于属性的测试(参见假设,您创建参数化测试,框架为您生成一组广泛的测试用例。这可以导致更全面的测试,而不需要你想出所有的边缘情况。突变测试(见宇宙射线)采用了一种非常不同的方法。它与您现有的测试用例一起工作,并实际上以某种小的方式(突变)修改您的源代码(测试中的代码),以查看您现有的测试是否失败。如果所有的测试仍然通过,你的测试代码是不完整的,因为它不能找到由变异引入的错误。
脚注
- 不确定是否有人真的会这么说,但有时你会遇到这样想的人。
- 你可能在想,*“我的用户不需要知道每个类的底层分数;他们只需要预测。那么,为什么我只是为了测试而改变我的回答呢?”*好问题!我们采用这种方法是出于方便和清晰的考虑。在实际实现中,您使用一个标志来指定是否返回基础分数,并且可能将此功能限制于某些用户。我们还可以使用模仿和修补来访问分数,而无需修改响应来包含模型分数。
- 如果你在运行
pytest时遇到问题,请尝试以下选项:py.test或python -m pytest。
用 Python 构建预测 API(第 4 部分):解耦模型和 API
在最后一部分中,我们用 pytest 查看了自动化测试,以验证我们的 API 是否正确地对模型评分。在本文中,我们将探讨如何将负责处理请求和准备响应的 API 功能与准备特性和模型评分所需的代码分离开来。
如果你是这个系列的新手,欢迎!此外,您可能希望从头开始,只是为了回顾我们已经完成的内容。
[## 用 Python 构建预测 API(第 1 部分):系列介绍&基本示例
好吧,你已经训练了一个模型,但是现在呢?如果没有人会使用,所有的工作都是没有意义的。在某些应用中…
towardsdatascience.com](/building-prediction-apis-in-python-part-1-series-introduction-basic-example-fe89e12ffbd3)
在这篇文章中,我们将会看到一些代码片段,但是完整的文件可以在 GitHub 上找到。
附注:我非常感谢这个系列给我的反馈。在前三个部分,我花了很多时间修改草稿,以确保尽可能清晰地呈现内容。我最近刚开始面试,所以时间有点紧。我将尽我所能继续定期发帖,但可能会有更长的延迟。我也会试着花更少的时间复习。如果有令人困惑的部分,请告诉我,我会尽力澄清。谢谢!
紧密耦合代码
我们将从第三部分中的虹膜预测模型的最后一个例子开始。我们将拒绝缺少petal_width的请求;对于所有其他缺失的特征,我们将使用均值插补进行评分。
正如我们所看到的,API 包含了大量与这个特定模型相关的代码。事实上,大部分代码都是特定于这个模型或 iris 模型的一个版本的:
- 我们从请求中检索硬编码的查询字符串参数,将它们转换成
float值,如果它们丢失或不能转换,就用默认值替换它们。 - 我们正在检查
petal_width是否丢失,并准备发送一个特定的错误消息给呼叫者。 - 我们正在创建特征向量。API 需要知道将特性放入
features列表的正确顺序。 - 类别标签包含在一个全局
MODEL_LABELS变量中。
我们可以修改这个代码来处理这个模型的变体。例如,对我们的特性使用硬编码默认值通常是糟糕的编码实践。我们可以将这些提取到一个配置文件中。这将允许我们用不同的平均插补值对模型的两个版本进行评分。
不幸的是,我们将很快发现其他的变化将很难适应。假设我们想要实现两个不同的模型意图:当前版本和一个如果缺少petal_width我们将估算,但是拒绝缺少sepal_width的请求。如果处理其中一个模型的缺失值的逻辑变得更加复杂怎么办?如果我们有一个完全不同的模型——一个使用 40 个特征来预测欺诈的模型——会怎么样?
正如在之前的一篇文章中提到的,我们可以为每种类型的模型创建单独的 API 或端点,但是引用 Raymond Hettinger 的话,“一定有更好的方法!”
拉开线球
在理想情况下,我们会将这种功能划分如下:
- API:接受请求,找到合适的模型进行评分,将原始数据传递给模型进行评分,并根据模型输出准备响应。
- Model:提取正确的字段,将原始数据准备成特性,预测是否可以对记录进行评分,如果不能,则引发错误,并将结果发送回 API。
包装我们的模型
有几种方法可以实现这一点,但是我们将从一种简单的方法开始。我们将在一些额外的代码中包装我们的模型,这些代码将处理特征检索和准备。
ModelWrapper类只有三种方法:__init__、predict和_prepare_features。当我们创建一个实例时,我们将传入带有class_labels(之前存储在MODEL_LABELS中)和feature_defaults ( dict包含插补值)的 Scikit-Learn 模型对象(model_object)。
API 将调用predict方法来获得预测。API 将直接传入request.args对象,模型将提取正确的字段。缺失值的提取和处理已转移到单独的方法_prepare_features。这些函数中的代码与我们最初在 API 中的代码几乎相同。
顺便提一句,如果您不熟悉 Python 中的单个前导下划线约定,这是一种将方法标记为“仅供内部使用”的方式——代码只打算由类(或其基类/子类)中定义的其他方法调用。然而,没有机制可以阻止任何人直接调用它。
最后要讨论的是我们如何将预测或错误返回给 API 代码。如果我们成功了,我们将返回一个带有label和probabilities的dict。在出现错误的情况下,我们将引发一个定制的异常,ModelError,它被定义在文件的顶部。一个奇怪的实现细节是我选择将这个异常作为ModelWrapper的一部分。稍后,我们将在 API 中使用它来引用try/except块中的这个异常。更干净的方法是在定义的地方有一个共享的工具模块/库。然后,模型包装器代码和 API 都可以引用这个定义。我认为共享库的方法可能更令人困惑(单独的文件),这就是为什么我选择了这个。
修改模型构建
既然我们已经定义了包装器代码,我们需要将它合并到我们的模型构建过程中。这样,我们可以使用joblib将模型、特征提取和评分代码一起打包/保存。
基本上,我们只是创建了一个dict,它有键的特性名称和特性的平均值作为相应的值。我们包装模型,然后保存这个包装的版本。为了清楚起见,我省略了生成我们的测试数据集的代码(参见第 3 部分,但是你可以在这里找到完整的文件。
新的 API
最后一步是修改我们的 API 来使用包装的模型。
大多数情况下,我们需要删除所有的特征提取和评分代码。MODEL以同样的方式加载(作为一个全局变量),但是这个版本除了包含我们的 Scikit 模型之外,还包含了所有的ModelWrapper代码。在一个try/except街区,我们叫MODEL.predict(),但现在我们只是路过request.args。如果出现异常,我们可以捕捉异常,并将错误消息转换成正确的响应(带有正确的状态代码)。如果没有出现异常,我们将返回一个带有标签和类别概率的dict。剩下的唯一事情就是通过jsonify()准备响应。
运行我们的测试
我们可以复制我们在第 3 部分中构建的test_predict_api.py文件(这里也有)。将它与所有其他文件放在同一个目录中,并运行pytest。这些测试应该都能通过。
这种方法的局限性
我们已经成功地将我们的模型评分代码从我们的通用 API 代码中分离出来。现在,API 的predict端点中没有任何东西是特定于这个 model⁴的,所以我们可以快速扩展这个 API 来对多个模型进行评分。
虽然这是一个巨大的进步,但这种方法存在一些挑战。主要的一点是特性生成代码与一般的模型构建代码是分开的。可能很难看到这一点,因为我们的模型构建非常简单。我们的训练数据集甚至没有任何缺失值,所以我们不需要做均值插补。假设我们正在使用一个更现实的模型,该模型需要分类变量的虚拟/一次性编码、插补或复杂的特征计算,而不仅仅是转换到float。使用我们当前的方法,我们可能需要维护这个特征化代码的两个版本:一个操作训练数据,另一个版本是ModelWrapper的一部分。
创建一个可以应用于训练和评分环境的统一特征代码库有巨大的好处,但这可能具有挑战性,并不总是可能的。通过在两个上下文中使用单一版本的特征化代码,我们减少了错误,并且可以更快地部署模型。这不总是可能的主要原因是每个上下文有不同的约束。在模型构建过程中,必须准备好整个训练数据集,因此可以优化实现以一次处理许多记录。对于评分,我们只需要准备一个单一的记录,但这应该尽快完成。
我们不打算在这篇文章中讨论它,但是 Scikit-Learn 有一个管道特性,让我们定义在模型构建和评分上下文之间共享的预处理/特征化步骤。然而,这也不是一个完美的解决方案。对可以在管道中定义的步骤类型有一些约束,这可能是限制性的。⁵
脚注
- Raymond 从事 Python 核心开发已经超过 15 年,并且是
collections和itertools模块的创建者。他还教授 Python 多年,是一位杰出的教育家。谷歌一下他,看看他所有的演讲,因为他们简直太棒了。 - 你可以把这些看作是 Java 类中的
private或protected方法。但是,Java 明确禁止从外部调用这些方法。Python 不会。 - 状态不是结果
dict的一部分,所以我们需要添加它。如果你不熟悉**result,这是执行关键字解包。快速举例:d = {'a': 1, 'b': 2, 'c': 3}。然后,调用:myfuct(**d)等价于调用:myfunct(a=1, b=2, c=3) - 我们仍然有硬编码的
MODEL全局变量,但是我们会在将来修复它。 - 可能有一个我们希望在 API 平台上运行的特定步骤,我们不需要在模型构建过程中运行。在以后的文章中,我们将关注日志/数据库存储。我们可能希望存储的一件事是准备好的特征向量,以便如果我们在 API 平台上遇到问题,我们可以验证特征是否被正确计算。为此,我们需要在模型评分发生之前在管道中插入一个“记录”步骤(管道的最后一步)。我不确定这是否可能。