【问题标题】:Remove a single row from a csv without copying files从 csv 中删除单行而不复制文件
【发布时间】:2018-09-18 20:17:00
【问题描述】:

有多个 SO 问题解决了该主题的某种形式,但对于仅从 csv 文件中删除一行(通常涉及复制整个文件),它们似乎都非常低效。如果我的 csv 格式如下:

fname,lname,age,sex
John,Doe,28,m
Sarah,Smith,27,f
Xavier,Moore,19,m

删除莎拉的行最有效的方法是什么?如果可能,我想避免复制整个文件。

【问题讨论】:

  • 这必须在 Python 中吗?有更适合的工具。
  • 我愿意接受建议,但更愿意留在 python 中。
  • 同意kabanus,如果这就是你想做的全部,为什么不使用sed/awk/grep。如果您想做其他事情,那么无论如何都可能需要在 Python 中迭代文件,所以天真的方法很好。
  • 顺便说一句,您可以 open()r+ 读取和写入同一个文件
  • 顺便说一句,这就是人们将这样的数据放入数据库而不是 CSV 的原因。只是说...

标签: python python-3.x csv


【解决方案1】:

你这里有一个基本问题。当前的文件系统(我知道)没有提供从文件中间删除一堆字节的工具。您可以覆盖现有字节,或写入新文件。因此,您的选择是:

  • 创建一个不带违规行的文件副本,删除旧的,然后在适当的位置重命名新文件。 (这是您要避免的选项)。
  • 用将被忽略的内容覆盖该行的字节。 确切地取决于要读取文件的内容,注释字符可能有效,或者空格可能有效(甚至可能是\0)。但是,如果您想完全通用,这不是 CSV 文件的选项,因为没有定义的注释字符。
  • 作为最后的绝望措施,您可以:
    • 阅读到要删除的行
    • 将文件的其余部分读入内存
    • 并用您要保留的数据覆盖该行和所有后续行。
    • 将文件截断为最终位置(文件系统通常允许这样做)。

如果你想删除第一行,最后一个选项显然没有多大帮助(但如果你想删除接近末尾的一行,它很方便)。它也非常容易在进程中间崩溃。

【讨论】:

  • 谢谢,这很有帮助。为什么最后一个选项容易崩溃?这就是@kabanus 在他的回答中使用的方法吗?
  • @SamBG 它不会容易崩溃,但如果它在写入过程中崩溃(例如因为断电),结果将是灾难性的。是的,这就是 kabanus 使用的方法。
  • @MartinBonner 这是狡猾的。大多数文件系统通过连接在双向链表中的集群来分发文件卷。因此,在内部插入/删除期间可能无法完成文件的重写。所有数据库都使用此功能。然而,这种对故障稳定的算法的实现相当费力。有时映射到内存的文件会为您工作(如果虚拟内存管理器足够智能)。
  • @SamBG 请阅读docs.python.org/3.0/library/mmap.html URL 有一个示例,几乎涵盖了您的情况。
【解决方案2】:

就地编辑文件是一项充满陷阱的任务(很像在迭代时修改可迭代对象),通常不值得这么麻烦。在大多数情况下,写入临时文件(或工作内存,取决于您拥有更多的存储空间或 RAM)然后删除源文件并用临时文件替换源文件将与尝试执行相同的性能同样的事情。

但是,如果您坚持,这里有一个通用的解决方案:

import os

def remove_line(path, comp):
    with open(path, "r+b") as f:  # open the file in rw mode
        mod_lines = 0  # hold the overwrite offset
        while True:
            last_pos = f.tell()  # keep the last line position
            line = f.readline()  # read the next line
            if not line:  # EOF
                break
            if mod_lines:  # we've already encountered what we search for
                f.seek(last_pos - mod_lines)  # move back to the beginning of the gap
                f.write(line)  # fill the gap with the current line
                f.seek(mod_lines, os.SEEK_CUR)  # move forward til the next line start
            elif comp(line):  # search for our data
                mod_lines = len(line)  # store the offset when found to create a gap
        f.seek(last_pos - mod_lines)  # seek back the extra removed characters
        f.truncate()  # truncate the rest

这将仅删除与提供的比较函数匹配的行,然后遍历文件的其余部分,将数据移到“已删除”行上。您也不需要将文件的其余部分加载到工作内存中。要测试它,test.csv 包含:

fname,lname,age,sex
约翰,能源部,28,米
莎拉,史密斯,27,f
泽维尔,摩尔,19,米

你可以这样运行它:

remove_line("test.csv", lambda x: x.startswith(b"Sarah"))

您将获得 test.csv 并在原地删除 Sarah 行:

fname,lname,age,sex
约翰,能源部,28,米
泽维尔,摩尔,19,米

请记住,当文件以二进制模式打开时,我们传递了一个 bytes 比较函数,以便在截断/覆盖时保持一致的换行符。

更新:我对这里介绍的各种技术的实际性能很感兴趣,但我昨天没有时间测试它们,所以稍微延迟了我创建了一个基准这可能会有所启发。如果您只对结果感兴趣,请一直向下滚动。首先,我将解释我的基准测试是什么以及我是如何设置测试的。我还将提供所有脚本,以便您可以在您的系统上运行相同的基准测试。

至于什么,我已经测试了这个和其他答案中提到的所有技术,即使用临时文件(temp_file_* 函数)和使用就地编辑(in_place_*)函数进行行替换。我在流(逐行读取,*_stream 函数)和内存(在工作内存中读取文件的其余部分,*_wm 函数)模式中都设置了这两种模式。我还使用mmap 模块(in_place_mmap 函数)添加了就地行删除技术。包含所有功能以及需要通过 CLI 控制的少量逻辑的基准脚本如下:

#!/usr/bin/env python

import mmap
import os
import shutil
import sys
import time

def get_temporary_path(path):  # use tempfile facilities in production
    folder, filename = os.path.split(path)
    return os.path.join(folder, "~$" + filename)

def temp_file_wm(path, comp):
    path_out = get_temporary_path(path)
    with open(path, "rb") as f_in, open(path_out, "wb") as f_out:
        while True:
            line = f_in.readline()
            if not line:
                break
            if comp(line):
                f_out.write(f_in.read())
                break
            else:
                f_out.write(line)
        f_out.flush()
        os.fsync(f_out.fileno())
    shutil.move(path_out, path)

def temp_file_stream(path, comp):
    path_out = get_temporary_path(path)
    not_found = True  # a flag to stop comparison after the first match, for fairness
    with open(path, "rb") as f_in, open(path_out, "wb") as f_out:
        while True:
            line = f_in.readline()
            if not line:
                break
            if not_found and comp(line):
                continue
            f_out.write(line)
        f_out.flush()
        os.fsync(f_out.fileno())
    shutil.move(path_out, path)

def in_place_wm(path, comp):
    with open(path, "r+b") as f:
        while True:
            last_pos = f.tell()
            line = f.readline()
            if not line:
                break
            if comp(line):
                rest = f.read()
                f.seek(last_pos)
                f.write(rest)
                break
        f.truncate()
        f.flush()
        os.fsync(f.fileno())

def in_place_stream(path, comp):
    with open(path, "r+b") as f:
        mod_lines = 0
        while True:
            last_pos = f.tell()
            line = f.readline()
            if not line:
                break
            if mod_lines:
                f.seek(last_pos - mod_lines)
                f.write(line)
                f.seek(mod_lines, os.SEEK_CUR)
            elif comp(line):
                mod_lines = len(line)
        f.seek(last_pos - mod_lines)
        f.truncate()
        f.flush()
        os.fsync(f.fileno())

def in_place_mmap(path, comp):
    with open(path, "r+b") as f:
        stream = mmap.mmap(f.fileno(), 0)
        total_size = len(stream)
        while True:
            last_pos = stream.tell()
            line = stream.readline()
            if not line:
                break
            if comp(line):
                current_pos = stream.tell()
                stream.move(last_pos, current_pos, total_size - current_pos)
                total_size -= len(line)
                break
        stream.flush()
        stream.close()
        f.truncate(total_size)
        f.flush()
        os.fsync(f.fileno())

if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("Usage: {} target_file.ext <search_string> [function_name]".format(__file__))
        exit(1)
    target_file = sys.argv[1]
    search_func = globals().get(sys.argv[3] if len(sys.argv) > 3 else None, in_place_wm)
    start_time = time.time()
    search_func(target_file, lambda x: x.startswith(sys.argv[2].encode("utf-8")))
    # some info for the test runner...
    print("python_version: " + sys.version.split()[0])
    print("python_time: {:.2f}".format(time.time() - start_time))

下一步是构建一个测试器,在尽可能隔离的环境中运行这些函数,尝试为每个函数获得一个公平的基准。我的测试结构如下:

  • 三个样本数据 CSV 生成为 1Mx10 的随机数矩阵(约 200MB 文件),并在它们的开头、中间和结尾分别放置一条可识别的线,从而为三个极端场景生成测试用例。
  • 在每次测试之前,主样本数据文件都被复制为临时文件(因为删除行具有破坏性)。
  • 采用各种文件同步和缓存清除方法,以确保在每次测试开始前清理缓冲区。
  • 测试是使用最高优先级 (chrt -f 99) 到 /usr/bin/time 运行的基准测试,因为不能真正信任 Python 在此类场景中准确衡量其性能。
  • 每个测试至少运行 3 次,以消除不可预测的波动。
  • 测试也在 Python 2.7 和 Python 3.6 (CPython) 中运行,以查看版本之间是否存在性能一致性。
  • 收集所有基准数据并将其保存为 CSV,以供将来分析。

不幸的是,我手头没有可以完全隔离运行测试的系统,因此我的数字是通过在管理程序中运行它获得的。这意味着 I/O 性能可能非常不平衡,但它应该同样影响仍然提供可比数据的所有测试。无论哪种方式,都欢迎您在自己的系统上运行此测试以获得您可以关联的结果。

我已将执行上述场景的测试脚本设置为:

#!/usr/bin/env python

import collections
import os
import random
import shutil
import subprocess
import sys
import time

try:
    range = xrange  # cover Python 2.x
except NameError:
    pass

try:
    DEV_NULL = subprocess.DEVNULL
except AttributeError:
    DEV_NULL = open(os.devnull, "wb")  # cover Python 2.x

SAMPLE_ROWS = 10**6  # 1M lines
TEST_LOOPS = 3
CALL_SCRIPT = os.path.join(os.getcwd(), "remove_line.py")  # the above script

def get_temporary_path(path):
    folder, filename = os.path.split(path)
    return os.path.join(folder, "~$" + filename)

def generate_samples(path, data="LINE", rows=10**6, columns=10):  # 1Mx10 default matrix
    sample_beginning = os.path.join(path, "sample_beg.csv")
    sample_middle = os.path.join(path, "sample_mid.csv")
    sample_end = os.path.join(path, "sample_end.csv")
    separator = os.linesep
    middle_row = rows // 2
    with open(sample_beginning, "w") as f_b, \
            open(sample_middle, "w") as f_m, \
            open(sample_end, "w") as f_e:
        f_b.write(data)
        f_b.write(separator)
        for i in range(rows):
            if not i % middle_row:
                f_m.write(data)
                f_m.write(separator)
            for t in (f_b, f_m, f_e):
                t.write(",".join((str(random.random()) for _ in range(columns))))
                t.write(separator)
        f_e.write(data)
        f_e.write(separator)
    return ("beginning", sample_beginning), ("middle", sample_middle), ("end", sample_end)

def normalize_field(field):
    field = field.lower()
    while True:
        s_index = field.find('(')
        e_index = field.find(')')
        if s_index == -1 or e_index == -1:
            break
        field = field[:s_index] + field[e_index + 1:]
    return "_".join(field.split())

def encode_csv_field(field):
    if isinstance(field, (int, float)):
        field = str(field)
    escape = False
    if '"' in field:
        escape = True
        field = field.replace('"', '""')
    elif "," in field or "\n" in field:
        escape = True
    if escape:
        return ('"' + field + '"').encode("utf-8")
    return field.encode("utf-8")

if __name__ == "__main__":
    print("Generating sample data...")
    start_time = time.time()
    samples = generate_samples(os.getcwd(), "REMOVE THIS LINE", SAMPLE_ROWS)
    print("Done, generation took: {:2} seconds.".format(time.time() - start_time))
    print("Beginning tests...")
    search_string = "REMOVE"
    header = None
    results = []
    for f in ("temp_file_stream", "temp_file_wm",
              "in_place_stream", "in_place_wm", "in_place_mmap"):
        for s, path in samples:
            for test in range(TEST_LOOPS):
                result = collections.OrderedDict((("function", f), ("sample", s),
                                                  ("test", test)))
                print("Running {function} test, {sample} #{test}...".format(**result))
                temp_sample = get_temporary_path(path)
                shutil.copy(path, temp_sample)
                print("  Clearing caches...")
                subprocess.call(["sudo", "/usr/bin/sync"], stdout=DEV_NULL)
                with open("/proc/sys/vm/drop_caches", "w") as dc:
                    dc.write("3\n")  # free pagecache, inodes, dentries...
                # you can add more cache clearing/invalidating calls here...
                print("  Removing a line starting with `{}`...".format(search_string))
                out = subprocess.check_output(["sudo", "chrt", "-f", "99",
                                               "/usr/bin/time", "--verbose",
                                               sys.executable, CALL_SCRIPT, temp_sample,
                                               search_string, f], stderr=subprocess.STDOUT)
                print("  Cleaning up...")
                os.remove(temp_sample)
                for line in out.decode("utf-8").split("\n"):
                    pair = line.strip().rsplit(": ", 1)
                    if len(pair) >= 2:
                        result[normalize_field(pair[0].strip())] = pair[1].strip()
                results.append(result)
                if not header:  # store the header for later reference
                    header = result.keys()
    print("Cleaning up sample data...")
    for s, path in samples:
        os.remove(path)
    output_file = sys.argv[1] if len(sys.argv) > 1 else "results.csv"
    output_results = os.path.join(os.getcwd(), output_file)
    print("All tests completed, writing results to: " + output_results)
    with open(output_results, "wb") as f:
        f.write(b",".join(encode_csv_field(k) for k in header) + b"\n")
        for result in results:
            f.write(b",".join(encode_csv_field(v) for v in result.values()) + b"\n")
    print("All done.")

最后(和 TL;DR):这是我的结果 - 我只从结果集中提取最佳时间和内存数据,但您可以在此处获得完整的结果集:@987654321 @ 和Python 3.6 Raw Test Data


根据我收集的数据,最后几点说明:

  • 如果工作内存是个问题(处理异常大的文件等),只有*_stream 函数占用空间小。在 Python 3.x 中,mmap 技术是一种中间方式。
  • 如果存储是一个问题,只有in_place_* 函数是可行的。
  • 如果两者都稀缺,唯一一致的技术是 in_place_stream,但代价是处理时间和增加的 I/O 调用(与 *_wm 函数相比)。
  • in_place_* 函数很危险,因为如果中途停止它们可能会导致数据损坏。 temp_file_* 函数(没有完整性检查)仅在非事务性文件系统上是危险的。

【讨论】:

  • 不错的解决方案 - 但请注意,您仍在读取文件的其余部分并进行写入。我想知道如果在单行或多行上进行截断是否会获得任何运行时优势,就像在我的解决方案中一样。
  • 我愿意打赌单次写入比多次写入要快,但赞成创造性的解决方案。
  • @kabanus - 它只是避免将所有内容读取到工作内存中(想想 10GB 文件等),必须以任何一种方式覆盖文件内容。而且,正如我在开头提到的那样,在这两种方法中,将所有内容写入临时文件然后覆盖旧文件在理论上应该是最快的选择 - 如果磁盘空间不是问题的话。
  • 啊,没想到 - 正确,在这种情况下这种方法更可取。
  • @kabanus - 你赌对了 ;) 我刚刚发布了我的基准测试,所以如果你有兴趣看看。
【解决方案3】:

使用 sed:

sed -ie "/Sahra/d" your_file

编辑,对不起,我没有完全阅读所有关于需要使用 python 的标签和 cmets。无论哪种方式,我都可能会尝试使用一些 shell 实用程序进行一些预处理来解决它,以避免其他答案中提出的所有额外代码。但是,由于我不完全了解您的问题,所以这可能是不可能的?

祝你好运!

【讨论】:

  • 虽然此代码可能会回答问题,但提供有关此代码为何和/或如何回答问题的额外上下文可提高其长期价值。
  • GNU sed does this by creating a temporary file and sending output to this file rather than to the standard output., gnu.org/software/sed/manual/sed.html
【解决方案4】:

这是一种方式。您确实必须将文件的其余部分加载到缓冲区,但这是我在 Python 中能想到的最好的:

with open('afile','r+') as fd:
    delLine = 4
    for i in range(delLine):
        pos = fd.tell()
        fd.readline()
    rest = fd.read()
    fd.seek(pos)
    fd.truncate()
    fd.write(rest)
    fd.close()

我解决了这个问题,就好像你知道行号一样。如果你想检查文本而不是上面的循环:

pos = fd.tell()
while fd.readline().startswith('Sarah'): pos = fd.tell()

如果没有找到 'Sarah' 会有一个例外。

如果您要删除的行更接近结尾,这可能会更有效,但我不确定阅读所有内容、删除行并将其转储回与用户时间相比会节省很多(考虑到这是一个 Tk 应用)。这也只需要打开一次并刷新一次文件,所以除非文件非常长,而且莎拉真的很远,否则它可能不会被注意到。

【讨论】:

  • 阅读其他答案后,这可能是任何程序所能做的最好的,但我不确定。
  • 您可以进行就地编辑,而无需将文件的其余部分读入工作内存。检查my answer。我不确定它是否比仅使用临时文件更有效。
  • 请注意,我已编辑此答案以不打开文件两次。不过,我还没有验证算法的正确性。
  • @Robin 谢谢,这是旧的复制粘贴留下的。
  • 查看我对this question 的回答。他们基本上做同样的事情(在 tcl 和 bash 中(有和没有dd),除了不需要一次性读取文件的其余部分(潜在的内存耗尽)。相反,您只需要读取块与被删除的行大小相同。我认为这在 python 中应该很容易实现。
【解决方案5】:

您可以使用 Pandas 做到这一点。如果您的数据保存在 data.csv 下,以下内容应该会有所帮助:

import pandas as pd

df = pd.read_csv('data.csv')
df = df[df.fname != 'Sarah' ]
df.to_csv('data.csv', index=False)

【讨论】:

  • 只要缺少对缺失整数值的支持,我会非常小心地立即读取和写入。当数字很小时,这不会有太大的区别,但是对于太大而无法用float精确表示的数字,例如长ID,这会给您带来很多麻烦
【解决方案6】:

删除莎拉的行最有效的方法是什么?如果可能,我想避免复制整个文件。

最有效的方法是用 csv 解析器忽略的内容覆盖该行。这避免了必须移动已删除行之后的行。

如果您的 csv 解析器可以忽略空行,请使用 \n 符号覆盖该行。否则,如果您的解析器从值中去除空格,请用(空格)符号覆盖该行。

【讨论】:

  • 我认为如何覆盖是这里的重点。如果您可以覆盖文件中的一行,您可以用'' 覆盖所有内容,不是吗?
  • @kabanus 仅覆盖文件的一部分。 seek + write,类似于您的解决方案所做的。
  • 是的,我猜 OP 想要一个操作方法,但我同意。
【解决方案7】:

这可能会有所帮助:

with open("sample.csv",'r') as f:
    for line in f:
        if line.startswith('sarah'):continue
        print(line)

【讨论】:

  • 这不是问题想要避免的确切天真的方法吗?它也被标记为 Python 3,print 是一个函数
猜你喜欢
  • 2023-03-08
  • 1970-01-01
  • 2011-05-03
  • 2017-12-27
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2017-02-05
  • 2020-07-18
相关资源
最近更新 更多