【问题标题】:Randomly sampling lines from a file从文件中随机采样行
【发布时间】:2018-06-11 13:11:07
【问题描述】:

我有一个 ~40gb 和 1800000 行的 csv 文件。

我想随机抽取 10,000 行并将它们打印到一个新文件中。

现在,我的方法是使用 sed 作为:

(sed -n '$vars' < input.txt) > output.txt

其中$vars 是随机生成的行列表。 (例如:1p;14p;1700p;...;10203p)

虽然这有效,但每次执行大约需要 5 分钟。这不是一个很长的时间,但我想知道是否有人对如何使其更快有想法?

【问题讨论】:

  • 您是如何生成列表的?是需要时间的sed,还是列表的生成?在 perl 中完成整个事情可能更快。
  • 虽然其他工具可能会提供稍快的结果,但请记住,磁盘 I/O 始终是这里的瓶颈。要计算行数,您必须找到换行符,这意味着扫描文件中的每个字节。
  • 另外,如果你已经在 Python 中,你可以使用 itertools 来获取这些行。不需要子流程
  • 我没有理解我认为的问题,为什么不使用shuf -n10000 input.txt &gt; output.txt ? (注意 shuf 会先洗牌,所以如果顺序很重要,请在问题中提及)
  • 我真的很喜欢这个问题,因为,A)它很有趣,B)它得到了很多有趣的答案,教会了我很多东西,即使我觉得很多海报没意识到你说的是 20KB 行。

标签: python bash sed


【解决方案1】:

具有相同长度的行的最大优点是您无需查找换行符即可知道每行的开始位置。如果文件大小为 ~40GB,包含 ~180 万行,则行长约为 20KB/行。如果要对 10K 行进行采样,则行之间的空间约为 40MB。这几乎肯定比磁盘上的块大小大三个数量级。因此,寻找下一个读取位置比读取文件中的每个字节要高效得多。

Seeking 将适用于行长不等的文件(例如,UTF-8 编码中的非 ascii 字符),但需要对方法进行少量修改。如果你有不等的行,你可以寻找一个估计的位置,然后扫描到下一行的开头。这仍然非常有效,因为您每需要阅读约 20KB 就会跳过约 40MB。由于您将选择字节位置而不是行位置,因此您的采样均匀性会受到轻微影响,并且您无法确定正在读取的行号。

您可以使用生成行号的 Python 代码直接实施您的解决方案。以下是如何处理所有具有相同字节数的行的示例(通常是 ascii 编码):

import random
from os.path import getsize

# Input file path
file_name = 'file.csv'
# How many lines you want to select
selection_count = 10000

file_size = getsize(file_name)
with open(file_name) as file:
    # Read the first line to get the length
    file.readline()
    line_size = file.tell()
    # You don't have to seek(0) here: if line #0 is selected,
    # the seek will happen regardless later.

    # Assuming you are 100% sure all lines are equal, this might
    # discard the last line if it doesn't have a trailing newline.
    # If that bothers you, use `math.round(file_size / line_size)`
    line_count = file_size // line_size
    # This is just a trivial example of how to generate the line numbers.
    # If it doesn't work for you, just use the method you already have.
    # By the way, this will just error out (ValueError) if you try to
    # select more lines than there are in the file, which is ideal
    selection_indices = random.sample(range(line_count), selection_count)
    selection_indices.sort()

    # Now skip to each line before reading it:
    prev_index = 0
    for line_index in selection_indices:
        # Conveniently, the default seek offset is the start of the file,
        # not from current position
        if line_index != prev_index + 1:
            file.seek(line_index * line_size)
        print('Line #{}: {}'.format(line_index, file.readline()), end='')
        # Small optimization to avoid seeking consecutive lines.
        # Might be unnecessary since seek probably already does
        # something like that for you
        prev_index = line_index

如果您愿意在行号分布中牺牲(非常)少量的均匀性,您可以轻松地将类似的技术应用于行长不等的文件。您只需生成随机字节偏移量,然后跳到偏移量之后的下一个完整行。在下面的实现中,假设您知道行的长度不超过 40KB。如果您的 CSV 具有以 UTF-8 编码的非 ascii unicode 字符,您将不得不这样做,因为即使这些行都包含相同数量的字符,它们也会包含不同数量的字节。在这种情况下,您必须以二进制模式打开文件,否则当您跳到一个随机字节时,如果该字节恰好是中间字符,您可能会遇到解码错误:

import random
from os.path import getsize

# Input file path
file_name = 'file.csv'
# How many lines you want to select
selection_count = 10000
# An upper bound on the line size in bytes, not chars
# This serves two purposes:
#   1. It determines the margin to use from the end of the file
#   2. It determines the closest two offsets are allowed to be and
#      still be 100% guaranteed to be in different lines
max_line_bytes = 40000

file_size = getsize(file_name)
# make_offset is a function that returns `selection_count` monotonically
# increasing unique samples, at least `max_line_bytes` apart from each
# other, in the range [0, file_size - margin). Implementation not provided.
selection_offsets = make_offsets(selection_count, file_size, max_line_bytes)
with open(file_name, 'rb') as file:
    for offset in selection_offsets:
        # Skip to each offset
        file.seek(offset)
        # Readout to the next full line
        file.readline()
        # Print the next line. You don't know the number.
        # You also have to decode it yourself.
        print(file.readline().decode('utf-8'), end='')

这里的所有代码都是 Python 3。

【讨论】:

  • @MrD。您还应该给@thanasisp 一些信任。在我写这个答案的时候,他也有同样的想法。
  • 我也赞成他的回答。我会保留你的答案,因为你从昨天开始就一直在关注我。 :)
  • @MrD。我很感激。
【解决方案2】:

如果所有行的长度相同,您可以使用dd 来解析整个文件或将其加载到内存中。

您必须知道已经执行wc -l 的行号,以及每行的精确字节长度,当然还要进行测试并确保所有行确实具有相同的长度。即使是wc 也会很慢,因为它会读取整个文件。

例如,如果每行是 20000 字节

#!/bin/bash

for i in `shuf -n 10000 -i 0-1799999 | sort -n`
do
    dd if=file bs=20000 skip="$i" count=1 of=output status=none \
        oflag=append conv=notrunc
done

这样我们循环并运行 10K 进程,我不确定它是否可以一次完成,所以虽然 dd 更快,但使用一种语言,如 Python 和 seek() 方法,(正如 @tripleee 和 @Mad 所说物理学家用 cmets 暗示)将具有一个过程的优势。

#!/usr/bin/python3
import random

randoms = random.sample(range(0, 1800000), 10000)
randoms.sort()

lsize = 20000

with open("file", "rb") as infile, open('output', 'wb') as outfile:
    for n in randoms:
        infile.seek(lsize * n)
        outfile.write(infile.read(lsize))

再节省一些时间,如果输出足够小,你可以把它保存在一个字节数组中,并在最后一次写入。

with open("file", "rb") as infile, open('output', 'wb') as outfile:
    buf = bytearray()
    for n in randoms:
        infile.seek(lsize * n)
        buf.extend(infile.read(lsize))
    outfile.write(buf)

【讨论】:

  • 除了要启动 10K dd 进程的可疑部分之外,这正是我的想法。我终于发布了一个答案,并 +1 给你。
  • 您发布的答案相同。我测试了dd 对于少数选择来说更快,但我认为没有循环就无法完成。
  • @MrD 这是一个有趣的问题,周围有很多信息,对我也很有用,谢谢。
【解决方案3】:

如果您的行的长度确实相同,您的 Python 脚本可以在文件中随机seek(),并且您知道要准确查找哪个索引才能准确地落在换行符之后的字符上。

为您的sed 脚本生成随机索引的 Python 脚本应该很容易适应这种方法。基本上,当您生成123p 以输入sed 时,改为寻找 122*line length 并读取您所在的行。

一个复杂的问题是 Python 3 禁止在以文本模式打开的文件中进行随机搜索(因为它需要知道 enooded 字符的开始和结束位置)。对于一个快速而肮脏的脚本,简单地读取和写入字节应该没问题(通常建议将字节解码为 Unicode,然后在写入之前再次编码;但由于您根本没有在 Python 中处理这些行,所以这是不必要的)。

【讨论】:

  • 我将进一步评论说,从 Python 调用 sed 似乎是一种糟糕的方法。完全在 Python 中执行相同的逻辑一点也不难,而且可能更有效,因为您不需要一次将整个选定行列表保存在内存中,当然也因为您避免了开销 -和可移植性问题 - 外部进程。
  • 我相信这种方法是 @MadPhysicist 在他的 cmets 中提到的。
【解决方案4】:

出于测试目的,让我们创建一个 1,800,000 行的文件:

$ awk 'BEGIN {for (i=1; i<=1800000; i++) print "line " i}' >file
$ ls -l file
-rw-r--r--  1 dawg  wheel  22288896 Jan  1 09:41 file

假设您不知道该文件中的行数,获取总行数的最快方法是使用 POSIX 实用程序wc

$ time wc -l file
 1800000 file

real    0m0.018s
user    0m0.012s
sys 0m0.004s

因此,要获得包含 1,800,000 行的文本文件的总行数是非常快的。

现在您知道总行数,您可以使用awk 打印这些行的随机样本:

#!/bin/bash

lc=($(wc -l file))
awk -v lc="$lc" -v c=10000 '
BEGIN{srand()}
int(lc*rand())<=c{print; i++}
i>=c{exit}
' file >rand_lines

在我的旧 iMac 上运行大约需要 200 毫秒。请注意,总数接近到 10,000,但可能更少,因为您经常会在达到 10,000 行之前到达文件末尾。

如果你想要以真正随机性为代价的正好 10,000,你可以这样做:

awk -v lc="$lc" -v c=10000 '
BEGIN{srand()}
int(lc*rand())<c * (1.01 or a factor to make sure that 10,000 is hit before EOF) {print; i++}
i>=c{exit}
' file >rand_lines

或者,或者,生成 1 到行数之间的 10,000 个唯一数字:

awk -v lc="$lc" -v c=10000 '
BEGIN{srand()
      while (i<c) {
        x=int(lc*rand())
        if (x in rl) continue  # careful if c is larger than or close to lc
        else {
        rl[x]
        i++} 
        }
     }
NR in rl' file >rand_lines

【讨论】:

  • 根据我的计算,OP 的每行约为 20KB。我认为这会影响您的时间安排几个数量级。您的示例文件大约为几兆字节,OP 为 40GB。这确实有所作为。
  • @MadPhysicist:公平点。但是,该方法可能是执行此操作的更快方法之一。
  • 我正在写一个我认为会更快的答案,但不幸的是它需要大量的编码。
【解决方案5】:

你会想要将数据插入数据库(例如 sqlite 或 mysql),然后在 SQL 中重复你的想法

select * from your_table where id in (1, 14, 1700, ...)

您还可以阅读如何从这个出色的教程http://jan.kneschke.de/projects/mysql/order-by-rand/ 中选择随机样本和

没有办法设计出运行速度明显更快的 shell 脚本,因为您的代码最终依赖于文件系统的基本工作方式。也就是说,为了获得良好的性能,您希望按顺序和分块访问磁盘。数据库旨在通过将数据在硬盘驱动器中的布局方式存储在名为 index 的单独文件中来解决此问题。它的工作方式与一本书的索引非常相似。

这是一个丰富的主题,需要一些学习。不过,如果您是数据库编程新手,那么 40 GB 数据集是一个不错的起点。

【讨论】:

  • 如果你每次都处理一个新的数据集,那么创建索引等的成本可能会超过你这样做时速度带来的好处。如果你多次处理同一个数据集,这可能是一个很好的解决方案;并且您处理它的次数越多,为此设置数据库的相对摊销成本就越小。
  • “没有办法设计一个运行得更快的 shell 脚本”。虽然有。您可以在文件中查找,跳过通常必须经过的大约 90% 的读取。
【解决方案6】:

从蒙特卡洛模拟世界中借用的另一个想法是循环这些线并在每次迭代中生成一个随机数。现在,如果你想从一组 180k 行中得到 10k 行,你的理由如下。您希望包含相关行的 10/180 更改。如果随机数小于或等于 10/180,则接受该行。否则,如果已收集到所需数量的行,则拒绝它或中断循环。

这种方法的缺点是不能保证准确地采样 10k 行。我也怀疑这种方法存在偏见,而且不够随机。

【讨论】:

  • 这与OP的问题无关
  • 另外,概率不会固定在 10/180。鉴于已经选择了k 行,选择行i 的概率将为p = (10000 - k)/(180000 - i)
猜你喜欢
  • 1970-01-01
  • 2015-07-22
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-08-08
  • 1970-01-01
  • 2016-10-16
  • 1970-01-01
相关资源
最近更新 更多