经过前面的教程,想必大家对于简单二分类、多分类、回归的建模方法都有了一定的了解。接下来,先不对模型本身进一步深入,而是针对机器学习中常见的数据载入、数据预处理、k折交差验证、参数选择、模型保存等操作进行基本的讲解,这些操作会贯穿后续学习的整个过程中。(如遇到部分操作需要有模型配合讲解的,则默认采用第(3)节中构建的Iris多分类模型)
1. 文本文件
文本文件是最常见的数据集格式之一(后缀不重要,可能为txt,也可能为csv),第一行一般为标题行(可能没有),后面每一行是一个样本的特征向量x+目标值y,列值之间以空格、制表符、逗号或者分号等隔开。读取此类文件最好的方式是利用pandas包的read_csv方法,可以有效应对实际应用中数据集操作的各种特殊需要(其它的还有numpy提供的loadtxt等,不过不建议看,学会pandas就够了)。先演示最最简单的不带标题行、单一数据类型的情况。
111;1.2;red 222;3.2;blue from pandas import read_csv data = read_csv('data.csv', delimiter=';', header=None) print(data) print(data.shape) # (2, 3) print(data.dtypes) # int64, float64, object 0 1 2 0 111 1.2 red 1 222 3.2 blue通常情况下,我们的数据集会带有头部信息,一般是第一行,此时把header参数去掉或者指定header=0即可(如果不是第一行,这里的0要改为对应的行索引值(从0开始算);注意此种情况下会把头部前面的行都忽略,比如这里设置第二行为头部,那么会忽略第一行,仅读入第三行的数据)。
co1;co2;co3 111;1.2;red 222;3.2;blue from pandas import read_csv data = read_csv('data.csv', delimiter=';', header=0) print(data) print(data.shape) # (2, 3) print(data.dtypes) # int64, float64, object co1 co2 co3 0 111 1.2 red 1 222 3.2 blue有时候,数据集开始会有一些备注信息,结尾也可能有些说明信息,我们读数据的时候希望能把开始的、结尾的某些行过滤了,此时可以用skiprows、skipfooter指定忽略开始、结尾的几行;其中skiprows除了可以直接传数字表明忽略开始的几行,还可以传数组,表示忽略哪几行。注意忽略操作是先于header指定的。此外,使用skipfooter时需要指定engine='python'。
# line xxx # line yyy co1;co2;co3 111;1.2;red 222;3.2;blue # line zzz from pandas import read_csv data = read_csv('data.csv', skiprows=2, skipfooter=1, delimiter=';', header=0, engine='python') print(data) print(data.shape) # (2, 3) print(data.dtypes) # int64, float64, object co1 co2 co3 0 111 1.2 red 1 222 3.2 blue有时候我们还需要对列进行操作,比如仅读入特定的某几列、读入的时候就对列值做某些操作、合并某几列的值为一列等。对应的参数分别为usecols、converters、parse_dates+date_parser;注意指定列的时候可以传列索引也可以传列名;还要注意,converter的处理在pandas的自动数据类型识别之前,比如co2列,虽然最终是float64,但是converter函数拿到的输入变量还是初始的str。下面分别演示效果:
# line xxx # line yyy co1;co2;co3 111;1.2;red 222;3.2;blue # line zzz from pandas import read_csv # data = read_csv('data.csv', skiprows=2, skipfooter=1, delimiter=';', header=0, # engine='python', usecols=[0, 2]) data = read_csv('data.csv', skiprows=2, skipfooter=1, delimiter=';', header=0, engine='python', usecols=['co1', 'co3']) print(data) print(data.shape) # (2, 2) print(data.dtypes) # int64, object co1 co3 0 111 red 1 222 blue # line xxx # line yyy co1;co2;co3 111;1.2;red 222;3.2;blue # line zzz from pandas import read_csv def f1(x): print(type(x)) return float(x)+1.0 # data = read_csv('data.csv', skiprows=2, skipfooter=1, delimiter=';', header=0, # engine='python', converters={1: f1}) data = read_csv('data.csv', skiprows=2, skipfooter=1, delimiter=';', header=0, engine='python', converters={'co2': f1}) print(data) print(data.shape) # (2, 3) print(data.dtypes) # int64, float64, object co1 co2 co3 0 111 2.2 red 1 222 4.2 blue # line xxx # line yyy co1;co2;co3 111;1.2;red 222;3.2;blue # line zzz from pandas import read_csv def f12(x, y): return x+y+"!" # data = read_csv('data.csv', skiprows=2, skipfooter=1, delimiter=';', header=0, # engine='python', parse_dates=[[0, 1]], date_parser=f12) data = read_csv('data.csv', skiprows=2, skipfooter=1, delimiter=';', header=0, engine='python', parse_dates=[['co1', 'co2']], date_parser=f12) print(data) print(data.shape) # (2, 2) print(data.dtypes) # object, object co1_co2 co3 0 1111.2! red 1 2223.2! blue实际应用中拿到的数据集往往会存在大量的空值,对于常见的nan、n/a、NaN等,pandas都能顺利将其识别为NaN,但是个别情况下我们需要将一下自定义的字符识别为NaN,比如‘-’,此时就需要指定na_values参数了。
# line xxx # line yyy co1;co2;co3 nan;n/a;NaN 222;3.2;- 333;-;- # line zzz from pandas import read_csv data = read_csv('data.csv', skiprows=2, skipfooter=1, delimiter=';', header=0, engine='python', na_values=['-']) print(data) print(data.shape) # (3, 3) print(data.dtypes) # float64, float64, float64 co1 co2 co3 0 NaN NaN NaN 1 222.0 3.2 NaN 2 333.0 NaN NaN所有该识别为NaN的值都识别为NaN后,下一步就是要清洗这些NaN值。首先,删除NaN过多的样本行、过多的特征列,这些数据留着反而会影响模型的训练。删除有两种方式,一种是全为NaN才删除,另一种是合法值少于一定阙值才删除,这个阙值要根据数据集的行列维度灵活确定。
# data.dropna(axis=0, how='all', inplace=True)
data.dropna(axis=0, thresh=1, inplace=True)
# 上面这两句等价,删全为NaN的行等价于删非NaN个数小于1的行
data.reset_index(drop=True, inplace=True)
# dropna之后记得重置索引,否则索引不会从0~length-1
print(data)
co1 co2 co3
0 222.0 3.2 NaN
1 333.0 NaN NaN
# data.dropna(axis=1, how='all', inplace=True)
data.dropna(axis=1, thresh=1, inplace=True)
data.reset_index(drop=True, inplace=True)
print(data)
co1 co2
0 222.0 3.2
1 333.0 NaN
然后对于剩下的少量NaN,用一定值去替换,可以是指定值0、-1等,也可以是列上一个值、下一个值、均值等等(替换时一般是按特征列操作,而不会拿一行的值去替换)。采用上一个值/下一个值的方式时需要注意,可能替换后还存在NaN,这俩替换方式需要结合着来。
# 下面对仍旧存在的NaN值进行替换(如果前面删除行列的时候设置how='any',
# 即有就删,那么这里就不会剩NaN值了)
data_replaced = data.fillna(value=0)
# 为便于演示, 先不用inplace;这种形式的替换会全局替换
print(data_replaced)
co1 co2
1 222.0 3.2
2 333.0 0.0
data_replaced = data.fillna(value={'co1': 0.1, 'co2': 1.1})
# 这种形式的替换会按列替换;可以先算出来每列的均值什么的再替换
print(data_replaced)
co1 co2
0 222.0 3.2
1 333.0 1.1
data_replaced = data.fillna(method='ffill', axis=0) # method还可以为bfill
print(data_replaced)
co1 co2
0 222.0 3.2
1 333.0 3.2
# 除了用fillna函数去替换,还可以针对每列自己去直接替换
data['co2'] = data['co2'].replace([3.2, None], [8.2, 7.2])
print(data)
co1 co2
0 222.0 8.2
1 333.0 7.2
2. 二进制文件
上面提到的数据集都是M*N这种二维形式的,对于更为复杂的数据形式,普通的文本文件处理起来就比较吃力了。存储复杂数据对象的格式主要有NPY、PICKLE、HDF5、JSON等,其中NPY、PICKLE、HDF5是二进制,JSON是文本;NPY底层默认调用PICKLE模块,所以和PICKLE一样据说“能序列化存储Python中所有的数据类型”;HDF5是一种全新的灵活的分层数据格式,其通过DATASET和GROUP两个概念实现多数据集的存储和管理,虽然在支持的数据类型种类上不及NPY和PICKLE,但是在科学数据存储领域,性能秒杀NPY和PICKLE。至于JSON,和HDF5一样支持的数据类型也不及NPY和PICKLE,只支持基本数据类型,要说优势的话,文本可读吧。
NPY文件
NPY是Numpy的专有二进制格式,其自带元素类型和形状等信息;NPZ是NPY的压缩形式,解压后会发现里面有多个NPY文件。NPY文件对应的存取方法为Numpy.save/Numpy.load,其中Numpy.save在保存时会自动调用Pickle模块将数据对象序列化,从而所以可以支持包括数组、字典等在内的多种数据类型,且也不像Numpy.savetxt/Numpy.loadtxt那样要求数组元素单一数据类型;可以设置allow_pickle=False关闭Pickle模块调用,不过不建议。另外,即使设置了allow_pickle=True,生成的NPY文件也没法用直接用Pickle模块载入。特别地,对于list类型,经过Numpy.save/Numpy.load后都会变成Numpy的ndarray类型,而Pickle的dump和load方法则会保留原格式,存的时候是ndarray,那载入后还是ndarray,存的时候是list,那载入后还是list。
import numpy as np
data1 = [
["Age", "Height", "Grade"],
[12, 178, 98],
[11, 170, 82]
np.save("data.npy", data1, allow_pickle=True)
npy = np.load("data.npy")
print(npy)
[['Age' 'Height' 'Grade']
['12' '178' '98']
['11' '170' '82']]
data2 = {
"Age": 12,
"Height": 178,
"Grade": 98
np.savez("data.npz", x=data1, y=data2)
npz = np.load("data.npz")
print(npz["x"])
print(npz["y"])
[['Age' 'Height' 'Grade']
['12' '178' '98']
['11' '170' '82']]
{'Grade': 98, 'Height': 178, 'Age': 12}
PICKLE文件
PICKLE是Pickle的专有二进制格式,其自带元素类型和形状等信息。NPY格式默认底层使用Pickle模块,所以两者支持存取的数据类型是一样的(虽然两个模块生成的文件不互通)。据说,Pickle能序列化Python中所有的数据类型,之后以二进制形式存储;与之对应的还有一个叫Json的模块,只能序列化Python中的数字、字符串、列表、字典等基本数据类型,之后以文本形式存储(不要看着是Json模块就觉得只能序列化标准JSON格式)(其实熟练使用Pickle之后就没必要了解Json模块了,反正存储的东西也不需要看)。此外,如果pickle文件是由Python2存储的且包含ndarray等数据类型,在用Python3 load的时候需要指定encoding='latin1'。
import pickle
data = {
"Age": 12,
"Height": 178,
"Grade": 98
with open("data.pickle", "wb") as file:
pickle.dump(data, file)
with open("data.pickle", "rb") as file:
data_new = pickle.load(file)
print(data_new)
{'Age': 12, 'Height': 178, 'Grade': 98}
此外,Pandas也有提供read_pickle方法用于读取pickle文件,且Pandas的read_pickle还额外提供了一个compression参数,取值:infer、gzip、bz2、zip、xz、None,默认为infer,即根据文件扩展名确定压缩方式。下面演示一下压缩格式Pickle的读取方式,先把data.pickle压缩成data.zip。
import pandas as pd
data_new = pd.read_pickle('data.zip', compression='zip')
print(data_new)
{'Age': 12, 'Height': 178, 'Grade': 98}
HDF5文件
HDF5是一种全新的灵活的分层数据格式,其通过GROUP和DATASET两个概念实现多数据集的存储和管理,虽然在支持的数据类型种类上不及NPY和PICKLE,但是在科学数据存储领域,性能秒杀NPY和PICKLE。HDF5的结构有点类似Linux系统的树形结构,GROUP是文件夹,DATASET是数据文件;此外,GROUP和DATASET都可以有自己的属性,即元数据。
import h5py
r Readonly, file must exist
r+ Read/write, file must exist
w Create file, truncate if exists
w- or x Create file, fail if exists
a Read/write if exists, create otherwise (default)
with h5py.File("data.hdf5", "w") as file:
file.attrs['size'] = 1
ds1 = file.create_dataset("ds1", data=[1, 2])
ds1.attrs['size'] = 2
usr = file.create_group("usr")
usr.attrs['size'] = 3
ds2 = usr.create_dataset("ds2", data=[2, 3])
ds2.attrs['size'] = 4
usr_bin = usr.create_group("bin")
usr_bin.attrs['size'] = 5
ds3 = usr_bin.create_dataset("ds3", data=[3, 4])
ds3.attrs['size'] = 6
file.close()
"ds1": [1, 2],
"usr": {
"ds2": [2, 3],
"bin": {
"ds3": [3, 4]
with h5py.File("data.hdf5", "r") as file:
print(file['/'])
print(file['/ds1'])
# print(file['ds1']), 这么写也是可以的
print(file['/usr/ds2'])
print(file['/usr/bin']['ds3'])
# 从这里可以看出来,想定位一个DATASET文件有两种方式
# 一是给全路径,而是利用字典结构
print(file['usr/bin/ds3'].value)
print(file['/usr/bin']['ds3'].attrs['size'])
file.close()
<HDF5 group "/" (2 members)>
<HDF5 dataset "ds1": shape (2,), type "<i4">
<HDF5 dataset "ds2": shape (2,), type "<i4">
<HDF5 dataset "ds3": shape (2,), type "<i4">
[3 4]
JSON文件
除了支持的数据类型种类比Pickle少了点,只有数字、字符串、列表、字典等,以及文件打开方式不是二进制,使用方法上与Pickle基本一样。
import json
data = {
"Age": 12,
"Height": 178,
"Grade": 98
with open("data.json", "w") as file:
json.dump(data, file)
with open("data.json", "r") as file:
data_new = json.load(file)
print(data_new)
{'Age': 12, 'Height': 178, 'Grade': 98}
3. 其它
IDX文件
IDX文件并不多见,但是著名的MNIST 手写数字识别数据集采用了该格式,所以有必要讲一下。IDX其实就是一种普通的二进制文件,只不过约定好了前几位用于定义整个文件的格式,从而能够存储 vectors and multidimensional matrices of various numerical types(这里还是用英文描述更加到位)。第1、第2位一般为0;第3位定义后面的数据类型,0x08 unsigned byte(1位)、0x09 signed byte(1位)、0x0B short(2位)、0x0C int(4位)、0x0D float(4位)、0x0E double(8位);第4位定义后面的数据维度。接下来的N个4位整数定义各维度的维数。然后就是具体的数据了。解析IDX文件需要用到struct模块。先去http://yann.lecun.com/exdb/mnist把数据集下载下来,主要有四个文件,训练集图片,训练集label,测试集图片,测试集label。目前网上能搜到的方法一般是根据数据集描述有针对性地去编写解析函数,且图片文件和label文件分别采用不同的解析函数,其实没必要,完全可以写一套通用的IDX文件解析方法,文件头部提供了足够的我们想要的信息,我们可以先去解析第3位、第4位,知道后面的数据类型以及维度;然后根据维度去取各个维度的维数,以确定后面的数据读取后怎么reshape。这里提供两种载入方式,一种是一次性载入,最为简单;另一种是限定载入个数,也即限定第一个维度的维数。
# 先演示一次性载入
import struct
import numpy as np
def decode_idx_ubyte(idx_ubyte_file):
# 读取二进制数据
bin_data = open(idx_ubyte_file, 'rb').read()
struct.unpack_from方法有三个参数,分别是解析格式、二进制流、开始解析位置
其中解析格式又分为两部分,打头的>表示高位优先,对应地<表示低位优先;
接下来的不定长字符串表示数据类型,比如x 仅占位、c 1位字符、B 1位无符号整数、
i 4位有符号整数、I 4位无符号整数、f 4位小数、d 8位小数、s 字符串;每种格式
前面还可以加数字表示个数,比如4i和iiii等价
# 先定义IDX第三位数据类型与struct中格式字符的映射关系
data_types = {
8: 'B', # 1位无符号整数
9: 'b', # 1位有符号整数
11: 'h', # 2位有符号整数
12: 'i', # 4位有符号整数
13: 'f', # 4位小数
14: 'd' # 8位小数
# 解析文件头信息
fmt_magic = ">2x2B"
offset = 0
data_type, dim = struct.unpack_from(fmt_magic, bin_data, offset)
fmt_dim = ">" + str(dim) + "i"
offset = offset + struct.calcsize(fmt_magic)
dim_list = struct.unpack_from(fmt_dim, bin_data, offset)
# 计算总读取长度需要把dim_list的几个维数乘起来
# 这里可以用reduce方法,第一个参数为俩输入变量的函数,函数返回结果后
# 再从列表里取第三个值然后送给函数再算,再取第四个值。。。
from functools import reduce
all_size = reduce(lambda x1, x2: x1 * x2, dim_list)
fmt_all = ">" + str(all_size) + data_types[data_type]
offset = offset + struct.calcsize(fmt_dim)
data = struct.unpack_from(fmt_all, bin_data, offset)
data_set = np.array(data).reshape(dim_list)
return data_set
image_list = decode_idx_ubyte("t10k-images.idx3-ubyte")
print(image_list.shape)
label_list = decode_idx_ubyte("t10k-labels.idx1-ubyte")
print(label_list.shape)
(10000, 28, 28)
(10000,)
# 再演示部分载入
import struct
import numpy as np
def decode_idx_ubyte_part(idx_ubyte_file, count):
# 读取二进制数据
bin_data = open(idx_ubyte_file, 'rb').read()
struct.unpack_from方法有三个参数,分别是解析格式、二进制流、开始解析位置
其中解析格式又分为两部分,打头的>表示高位优先,对应地<表示低位优先;
接下来的不定长字符串表示数据类型,比如x 仅占位、c 1位字符、B 1位无符号整数、
i 4位有符号整数、I 4位无符号整数、f 4位小数、d 8位小数、s 字符串;每种格式
前面还可以加数字表示个数,比如4i和iiii等价
# 先定义IDX第三位数据类型与struct中格式字符的映射关系
data_types = {
8: 'B', # 1位无符号整数
9: 'b', # 1位有符号整数
11: 'h', # 2位有符号整数
12: 'i', # 4位有符号整数
13: 'f', # 4位小数
14: 'd' # 8位小数
# 解析文件头信息
fmt_magic = ">2x2B"
offset = 0
data_type, dim = struct.unpack_from(fmt_magic, bin_data, offset)
fmt_dim = ">" + str(dim) + "i"
offset = offset + struct.calcsize(fmt_magic)
dim_list = struct.unpack_from(fmt_dim, bin_data, offset)
# 计算实际读取的个数
dim_list = list(dim_list)
dim_list[0] = dim_list[0] if dim_list[0] <= count else count
from functools import reduce
one_size = int(reduce(lambda x1, x2: x1 * x2, dim_list)/dim_list[0])
# 这里这么写是为了应对(None, )的情况
fmt_one = ">" + str(one_size) + data_types[data_type]
offset = offset + struct.calcsize(fmt_dim)
data_set = np.empty(dim_list)
for i in range(dim_list[0]):
data = struct.unpack_from(fmt_one, bin_data, offset)
data_set[i] = np.array(data).reshape(dim_list[1:])
offset = offset + struct.calcsize(fmt_one)
return data_set
image_list = decode_idx_ubyte_part("t10k-images.idx3-ubyte", 8000)
print(image_list.shape)
label_list = decode_idx_ubyte_part("t10k-labels.idx1-ubyte", 8000)
print(label_list.shape)
(8000, 28, 28)
(8000,)