Python – 在文件中存储数据

原文链接

这是一系列涉及python数据存储的文章中的第一篇。其他文章见:

存储二进制数据及序列化

使用数据库存储数据

在Python中使用HDF5文件

在大多数python程序中,将数据存储下来以备后续使用都是其核心部分。无论你是在实验室中做测量,还是要设计一个网络应用,你都会需要将信息以一种稳定的方法存储下来。例如,如果你要在完成实验后分析结果,那么结果就必须存储下来。又或者你可能需要将所有注册了你的网站的用户的邮箱地址存储下来。

即使存储数据是最重要的事情,但在不同的场合下还是要使用不同的方法。必须考虑到不同的因素,例如产生的数据量,数据是否是自明的,你之后要如何使用这些数据等等。这篇文章将带你开始了解不同的存储数据的方法。

使用 Numpy 的纯文本文件

要在硬盘中存储数据,一种常见的方法是使用纯文本文件。我们可以先看一个简单的例子:指定长度的一串numpy数组。我们使用以下方式产生一个数组:

import numpy as np
data = np.linspace(0,1,201)

以上代码会生成一个具有201个值的数组,均匀的分布在0到1之间。这就是说,每个元素的值会是0,0.005,0.01...,如果你要将这些值存储在一个文本文件里,numpy可以使用savetxt方法很容易做到这点,如下:

np.savetxt('A_data.dat', data)

之后如果你用任何文本编辑器打开 a_data.dat文件,你可以看到一个包含了数组中所有值的列。这很方便,因为任何一种支持读文本文件的程序都可以访问它。到此为止,这个例子还是非常简单,因为只存储了一个数组。假设要存储两个数组,就像要制作一个简单的二维函数图像所需要的值那样。首先,让我们生成两个数组:

x = np.linspace(0, 1, 201)
y = np.random.random(201)

使用以下语句可以很容易的存储数据:

np.savetxt('AA_data.dat', [x, y])

如果再打开这个文件,可以看到文件中并不是两个列,而是两个非常长的行。这通常来说不是什么问题,但如果用文本编辑器或者其他什么程序打开,就会非常难读。如果你想将数据存储为两个(或多个)列,你需要将他们叠放。代码是这样的:

data = np.column_stack((x, y))
np.savetxt('AA_data.dat', data)

再次查看文件,你可以看到x的值都存在左边的列中,而y的值在右边。这种格式更易于阅读,并且,我们后面也会发现,允许你只读取部分数据。然而,这里还缺失了一些重要信息。如果别人打开了这个文件,并没有信息来提示他每列分别是什么意思。这个例子里,更简单的解决方法是给每列加上一个标题:

header = "X-Column, Y-Column"
np.savetxt('AB_data.dat', data, header=header)

再次查看文件,你可以看到每列有一个标题解释其含义。注意到这一行以一个#号开头。为了识别出哪些行是属于标题,而不是数据,这是一种相当标准的做法。如果你想要添加一个多行标题,代码如下:

header = "X-Column, Y-Column\n"
header += "This is a second line"
np.savetxt('AB_data.dat', data, header=header)

这段代码中值得注意的是第一行行末的'\n'。这是一个换行符,等同于在写文档的时候敲一下回车所输入的结果。这个字符告诉Python在写入文件的时候跳入下一行。

使用Numpy读取所保存的数据

当然, 保存到文件只是任务的一半。另一半是读取出来。幸运的是,用Numpy这也很容易:

data = np.loadtxt('AB_data.dat')
x = data[:, 0]
y = data[:, 1]

注意,这里已经自动丢弃了标题。始终使用相同的库(在本例中为numpy)的优点是,它使执行写/读循环变得非常容易。如果您试图从另一个程序生成并使用另一个字符开始注释的文件中读取数据,那么您可以非常容易地调整上面的代码:

data = np.loadtxt('data.dat', comments='@')

在上面的示例中,代码会跳过以@字符开头的所有行。

使用 Numpy 存储部分文件

这种情况也非常常见:在数据采集或生成过程里,将其保存到文件中。例如,这允许您观察实验的进展,并且即使程序出了问题,数据也可以是安全的。代码与我们之前所做的非常相似,但并不完全相同:

import numpy as np

x = np.linspace(0, 1, 201)
y = np.random.random(201)

header = "X-Column, Y-Column\n"
header += "This is a second line"
f = open('AD_data.dat', 'wb')
np.savetxt(f, [], header=header)
for i in range(201):
data = np.column_stack((x[i], y[i]))
np.savetxt(f, data)

f.close()

首先要注意的是,我们使用open命令显式地打开文件。这里重要的部分是我们在末尾添加的wbw代表写模式,即如果文件不存在,就会创建它,如果它已经存在,就会被擦除并从头开始。第二个字母b表示二进制模式,用于让numpy将数据附加到文件中。为了生成标题,我们首先使用标题保存一个空列表。在for循环中,我们一行一行地将每个值保存到文件中。

在上面的例子中,如果您打开文件,您将看到它与前面完全一样。但是,如果您在for循环中添加一条sleep语句并打开文件,您将看到保存了的一部分。记住,并不是所有操作系统都允许同时在两个不同的程序中打开文件。此外,并不是所有文本编辑器都能够从外部注意到对文件的更改,这意味着除非重新打开文件,否则不会看到对文件的更改。

立即保存更改

如果您开始经常使用保存部分数据的这个方法,您会注意到,特别是当您的程序崩溃时,一些数据点可能会丢失。写入磁盘是由操作系统处理的一个步骤,因此根据使用哪个操作系统以及计算机有多忙,其行为和结果可能会有很大的区别。Python 将写指令放到队列中,这意味着写指令本身可能会在很晚的时候才得到执行。如果您希望确保所有的更改会被写入,特别是当您意识到您的程序可能会产生未处理的异常时,您可以添flush命令。很简单,如下所示:

f = open('AD_data.dat', 'wb')
for i in range(201):
[...]
f.flush()

这样可以确保每次都将数据写入硬盘。Python 通常使用操作系统的默认值来处理写入事件的缓存。然而,如果要突破这种限制,如何获取控制权,以及同时清楚这么做的代价,都是非常重要的。

With 声明

处理文件的时候,重要的是确保完成之后正确的关闭文件句柄。如果不这么做,可能就会损坏数据。在上面的例子里,可以看出,如果在for循环里出现错误的话,f.close()这一行就永远不会被执行了。为了避免此类问题,Python 提供了with声明,一种用法如下:

with open('AE_data.dat', 'wb') as f:
np.savetxt(f, [], header=header)
for i in range(201):
data = np.column_stack((x[i], y[i]))
np.savetxt(f, data)
f.flush()
sleep(0.1)

第一行是这里的关键所在。这里没用直接使用f=open()语句,而是使用了with声明。这样文件就仅会在当前语句块内被打开。一旦语句块完成,即使出现异常,文件也会被关闭。with可以节约大量的输入,因为无需处理异常,也不用在之后关闭文件。这在起初会显得是一点小小的好处,但谨慎的开发者应该广泛的使用这种用法。

要详述with声明,值得另写一篇文章来阐述,这已经列入计划了。但就现在而言,你只要在读到的时候懂得其含义就好。

更底层的写入文本文件的方法

到目前为止,我们已经看到了如何使用numpy保存数据,因为这是很多应用中的标准。然而,这可能没法适应所有的场合。Python有自己读写文件的方法。让我们先从写入文件开始。模式很简单:

f = open('BA_data.dat', 'w')
f.write('# This is the header')
f.close()

或者使用with语句:

with open('BA_data.dat', 'w') as f:
f.write('# This is the header')

open命令至少需要一个参数,文件名。第二个参数是打开文件的模式。基本上有三种模式:r表示读取且不修改文件;a表示追加,或者在文件不存在的情况下创建文件;w则表示即使文件存在,也会创建一个空文件来覆盖它。如果不写明模式的话,会默认r模式,如果文件不存在,则会引发FileNotFound异常。

既然我们现在已经写入了标题,那么应该接着写入一些数据。比如,我们可以试试下面的代码:

x = np.linspace(0,1,201)
with open('BB_data.dat', 'w') as f:
f.write('# This is the header')
for data in x:
f.write(data)

然而这将引发TypeError错误,因为我们试图写入的并不是一个字符串,在这个例子中,是一个numpy数字。因此,无论要写入什么,应首先将其转化为一个字符串。对数字来说,很容易,只需将最后一行替换为

f.write(str(data))

如果此时打开文件,会注意到情况比较奇怪。标题和数组里的所有元素都被写在同一行,而且之间也没有任何的分隔符分割。如果仔细想想,这应该是可以想到的情况。因为我们用了比较底层的命令,所以可以更精确地控制写文件的内容和方式。

如果您还记得上一节中的内容,那么可以使用\n字符在写入文件后生成新行。代码如下:

x = np.linspace(0,1,201)
with open('BB_data.dat', 'w') as f:
f.write('# This is the header\n')
for data in x:
f.write(str(data)+'\n')

之后再打开文件,就可以看到所有的数据点都很好地堆叠起来了。同时,也会注意到,并不是所有的值都具有同等的长度。比如,您可以找到诸如0.01,0.005这样的数据,还有0.17500000000000002。前两个很正常,但是,第三个就有点怪了。这个数字的最后一位是由于浮点数误差造成的。想要了解更多,您可以阅读 Oracle Website (更偏向技术)或者 维基百科(更面向大众一些)上的内容。

格式化输出

在将数据写入磁盘时,要考虑的最重要的事情之一,就是如何构造数据以便之后读取。在上面的部分中,我们已经看到, 如果不在每个值后面添加换行符,他们就会在同一行上一个接一个的被打印出来,这使得数据几乎不可能读取。因为每个数字的长度并不一样,因此无法将其分解为更小的信息块。

因此,从长远来看,格式化输出对于让数据有意义是非常重要的。Python提供了格式化字符串的不同方式。我会选择我通常使用的,但你可以自由探索其他的选择。让我们首先使用格式调整上面的示例。你可以打印每个值到一个不同的行,像这样:

x = np.linspace(0,1,201)
with open('BC_data.dat', 'w') as f:
f.write('# This is the header\n')
for data in x:
f.write('{}\n'.format(data))

如果您运行这段代码,输出文件是一样的。使用format的时候,{}会被替换为data。这里和我们之前使用str(data)是等效的。然而,假设您想将所有输出的值控制为相同数量的字符,那么可以将最后一行替换为:

f.write('{:.2f}\n'.format(data))

这样可以得到类似0.00, 0.01等等的值。您在{}之间放进去的,是格式字符串,它告诉python如何将数字转换为字符串。在这个例子里,代码是说将这些数字处理为带两位小数的定点数。从原则上来说,这样看起来不错,但要注意到会丢失信息。例如,像0.005就会被四舍五入到0.01。因此,为了不丢失重要的信息,必须要对想要的结果非常确定。如果您做的实验的精度要求是0.1,那并不用关心0.005的出入,但如果并非如此,那就已经丢失了一半的精度。

想要让格式合乎要求,要费点脑筋。如果我们想要至少存储三位精度,那么应当将代码改为:

f.write('{:.3f}\n'.format(data))

那么现在所有的小数都被存储到了后三位。格式化字符串本身也应单列一篇文章讲述。但这里已经看到了一些基本的选择。如果您是在处理整型数字,或者,在处理较大的浮点数(不介于0和1之间),您可能需要指定这些数字所需的存储空间。例如,可以试试这么写:

import numpy as np

x = np.linspace(0,100,201)
with open('BC_data.dat', 'w') as f:
for data in x:
f.write('{:4.1f}\n'.format(data))

这个命令可以让python知道每个数字应该总共占据4个长度,小数点后仅有一位。因为第一个数字只有3个字符(0.5),所以在数字前面会增加一个空格。之后,10.0会直接从1开始,这样小数点位可以很好地对齐。然而,您之后会发现100.0就错开了一位(因为其需要占据5个字符长度,而不是4个)。

您可以尝试其他各种格式。比如将信息向左或向右对齐,在任意一侧添加空格或其他字符,等等。我保证之后会补上这篇文章。但就现在来说,这已经足够了,让我们继续将数据放入文件。

将数据存储成列

让我们回到之前存储了两列数据的那个示例。现在我们想要在不使用numpysavatxt的情况下做到同样的事情。通过前面所了解的格式化知识,我们可以这么做:

import numpy as np

x = np.linspace(0,100,201)
y = np.random.random(201)

with open('BD_data.dat', 'w') as f:
for i in range(len(x)):
f.write('{:4.1f} {:.4f}\n'.format(x[i], y[i]))

检查保存的文件,您将见到两列数据,用空格隔开。您也可以用不同的办法来改写写入数据这行,比如,可以这样:

f.write('{:4.1f}\t{:.4f}\n'.format(x[i], y[i]))

这样,隔开两列的就不是空格,而是一个制表符。您可以以您喜爱的方式来构造文件。然而,必须要谨慎,并提前考虑好,如何在出现数据不一致的情况下检索数据。

读取数据

我们将数据保存到文件之后,重要的就是将其读回到程序里。第一种方法并不是正统的方法,但它能证明一个观点。如果数据是用numpyloadtxt的方法写入的,那么可以这样读取:

import numpy as np

data = np.loadtxt('BD_data.dat')

使用文本文件的优点之一,是相对容易在其他程序里读取。甚至您的文本编辑器也可以理解这些文件中的内容。当然,您也可以不使用numpy来读取文件,仅仅使用Python的内建方法。最简单的是:

with open('BD_data.dat', 'r') as f:
data = f.read()

然而,如果您查看data变量,您会发现这是一个字符串。毕竟,纯文本文件就只有字符串而已。根据您之前构造文件的方式不同,将这些数据转化为数组,列表等等可能会难度不一。但是,在深入这些细节之前,另一种读取文件的方式是逐行读取:

with open('BD_data.dat', 'r') as f:
data = f.readline()
data_2 = f.readline()

在这个例子里,data会存有标题,因为它存储了文件的第一行,而data_2则会存有第一行数据。当然,这仅仅读取了整个文件的前两行。要读取所有行,我们可以这么做:

with open('BD_data.dat', 'r') as f:
line = f.readline()
header = []
data = []
while line:
if line.startswith('#'):
header.append(line)
else:
data.append(line)
line = f.readline()

现在您会发现事情逐渐变得更复杂了。打开文件之后,我们先读取了第一行,然后进入了一个循环,只要文件里还有未读取的行,这个循环就会持续运行、我们用了两个空列表来保存标题和数据信息。对每一行,我们都会检查其是否以#开头,这对应着标题(或者注释)。然后,我们将其他的行追加到数据里。

如果您查看data变量,会注意到它其实没什么用。如果您正在看带有两列的例子,您会看到data是一个每个元素都形如0.0t0.02994n的列表。如果我们想要重构我们之前的信息,我们必须将写入的过程逆转。首先要注意的是,两个值是以\t分割的,因此代码如下:

with open('BD_data.dat', 'r') as f:
line = f.readline()
header = []
x = []
y = []
while line:
if line.startswith('#'):
header.append(line)
else:
data = line.split('\t')
x.append(float(data[0]))
y.append(float(data[1]))
line = f.readline()

代码开始看起来是一样的。但我们之后将data分成了xy,这里,最大的修改是我们用方法split来分离一个字符串。由于我们的列是由\t隔开的,所以这里也用\t来分割。data将有两个元素,也就是两列,我们分别将其附加到xy里。当然,我们想要的并不是字符串而是数字,因此这里我们将其转换为浮点数。

通过上面的步骤,您可以看到,恢复numpyloadtxt功能是可行的,只是需要很多努力。上面的代码只能在有两列的情况下工作,如果文件只有一列,或者两列以上,就会无法工作。loadtxt则并不需要知道文件的列数,它可以自己解析文本来发现。然而,您并不是总有numpy可以使用的,或者有时您需要更高级别的控制数据读写的方式,所以还是要学习如何使用内建的方法来做。

偷师

开源软件的诸多优点中的一项,就是你可以自由地查看其代码,从而了解他们的功能并从中学习。在上面的例子中,我们开发出了一个专门的解决方法,可以处理两列数据,但是不能更多,也不能更少。但是,如果我们使用loadtxt,我们并不需要指定有多少列,这个方法可以自己读出列数。我们来看看loadtxt的源码,试着理解它是怎么做到的,然后改进我们自己的代码。

您所注意到的第一件事,大概就是连同注释在内,方法loadtxt的代码长达376行。这和我们只有10行的只能读两列数据的代码可完全不是一回事儿。在1054行,您可以找到first_vals = split_line(frst_line),然后转到991行,这里numpy定义了如何分割行内的数据。您可以看到这里同样也只是简单的使用了line.split(delimiter),并且分隔符(delimiter)可以是None(请看loadtxt定义的同一行(773))。看到这里,您可以继续看Python官方文档中关于split的部分

我们可以做的是寻找第一行带有数据的行,然后用固定的分隔符将其分隔开,或者我们不指定分隔符,让Python来决定如何分割。一旦我们读取了第一行,我们就可以知道有多少列,并且我们假设所有其他行的列数是一样的。如果不是这样,那么我们可以抛出一个数据格式不正确的异常。另外需要注意的是,loadtxt还负责将元素解析为适当的数据类型。假设我们正在处理浮点数,并且使用了float(data[0]),如果我们试图加载的是一个字符串的话,这么做就会引发错误。

numpy所做的那样,相对灵活的读取数据,是一件比较复杂的事。阅读他人的代码的好处是,你可以从经验丰富的开发人员那里学到很多东西,你会发现他们可以预料到一些你从未想到的问题。那么您也可以在您的电脑上使用相同的方法,而无需依赖在电脑上安装numpy的库。无论何时,如果您认为您在解决一些已经解决过的问题,不妨试着利用这条经验。阅读代码是学习的一种非常强大的资源。

保存非数字的数据

到此为止,我们所处理的都是数字,这就是使用numpy有着很大的优势的原因。然而,很多应用都需要处理不同类型的数据。我们不妨从最简单的开始:保存字符串。有一种学习机器学习的时候非常流行的数据集,我们称之为Iris数据集。它包括对三种不同花卉的几个参数的观察。

我并不打算在这里重建这个数据集,而是将其作为一种灵感。设想你在观察一些花卉,每次都需要从三个选项中选出对应的一种花。然而,并不是每次观察都是正确的,有一些被标记为错误的观察。我们可以用一些随机的数据很容易的创建一个文件:

import random

observations = ['Real', 'Fake']
flowers = ['Iris setosa', 'Iris virginica', 'Iris versicolor']

with open('DA_data.dat', 'w') as f:
for _ in range(20):
observation = random.choice(observations)
flower = random.choice(flowers)
f.write('{} {}\n'.format(observation, flower))

有两种观察结果,和三种不同的花卉。每次从中任意选择一种观察结果和一种花卉,并写入文件。当然,我们并不一定要局限于字符串数据。我们也可以存一些数值进去。原本数据集中包含有四类数值:萼片和花瓣的长度和宽度。我们可以修改程序,添加一些假的数据:

import random

observations = ['Real', 'Fake']
flowers = ['Iris setosa', 'Iris virginica', 'Iris versicolor']

with open('DB_data.dat', 'w') as f:
for _ in range(20):
observation = random.choice(observations)
flower = random.choice(flowers)
sepal_width = random.random()
sepal_length = random.random()
petal_width = random.random()
petal_length = random.random()

f.write('{} {} {:.3f} {:.3f} {:.3f} {:.3f}\n'.format(
observation,
flower,
sepal_length,
sepal_width,
petal_length,
petal_width))

此时查看文件,可以看到和之前相同的信息,再加上我们额外添加的四个数值。可能您已经看到了这种方法的局限性,让我们更仔细的来看。

读取非数值数据

和之前一样,读取非数值数据和读取数值数据一样简单。举例来说,可以这么做:

with open('DB_data.dat', 'r') as f:
line = f.readline()
data = []
while line:
data.append(line.split(' '))
line = f.readline()
print(data)

可以看到,这里使用了空格作为分隔符,这在上面的例子里似乎是一个不错的主意。但是,如果仔细观察data变量,您会发现花的名字被分开了,我们最终得到了一个含有6个数据的行,而不是以为的5个。这是一个简单的例子,由于一个数据中含有一个空格,因此,我们必须将含有名字的两个数据重新合成一个名字。

而一些更复杂的数据,比如句子,就会需要更仔细的处理。在一个句子里,空格的数量是不定的,因此您会很难弄清哪些部分属于哪个数据列。您可以在保存文件的时候,就选择用逗号来替换空格。如果所保存的数据中原本没有逗号,那么这样的方法是可行的。

如果您选择了使用逗号作为分隔符,您就会得到一个通常被称为“逗号分隔数值”文件,即csv文件。您可以到Github查看我之前做好的文件。这种文件不仅可以用文本编辑器打开,还可以用诸如Excel,Libre Office,Matlab等等这样的数学软件打开。如果您查看Github上的文件,您会发现它已经有很好的格式了。围绕着这种格式有好几种标准,您可以试着去模仿使用。

当然,如果您的数据中含有逗号,那文件就会被破坏。数据的完整性并不会被损害,但是将变得难以读取,很难在不犯错的情况下将其解读出来。毕竟,存储数据的意义就在于能将其解读回来,如果在这个过程中发生了异常,也就无法确定数据的含义了。当然,可以不使用逗号,或者任何单个字符作为分隔符。举例来说,您可以用一个连起来的句点和逗号作为分隔符。

所以,当您存储数据的时候,不仅应当考虑存储的过程,还应该考虑如何以一种确定的方式将数据解读出来。如果仅仅需要存储数值,那么选择一个字母来作为分隔符看起来就是个不错的主意。使用逗号好像也没什么问题,一直到您后来意识到,许多国家是使用逗号作为小数点的。

这就是关于如何存储数据的全部内容吗?当然不是,后面还有很多内容。

一如既往,所有的示例代码可以在这里找到,本文则在这里

发表评论

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据