从大型CSV文件中快速提取行块

时间:2016-12-17 19:44:15

标签: python performance csv

我有一个大型的CSV文件,其中包含与此相关的库存相关数据:

  

Ticker Symbol,Date,[some variables ...]

因此每一行都以符号开头(如“AMZN”),然后有日期,然后在所选日期有12个与价格或数量相关的变量。这个文件中有大约10,000种不同的证券,我每天都有一条线,每条股票都是公开交易的。该文件首先按字母顺序按股票代码排序,按时间顺序按日期排序。整个文件大约是3.3 GB。

我想要解决的任务类型是能够针对当前日期提取给定股票代码的最新 n 数据行。我有代码执行此操作,但根据我的观察,平均每次检索需要大约8-10秒(所有测试都提取100行)。

我有我想要运行的功能,需要我抓住数百或数千个符号的这些块,我真的想减少时间。我的代码效率很低,但我不确定如何让它运行得更快。

首先,我有一个名为getData的函数:

def getData(symbol, filename):
  out = ["Symbol","Date","Open","High","Low","Close","Volume","Dividend",
         "Split","Adj_Open","Adj_High","Adj_Low","Adj_Close","Adj_Volume"]
  l = len(symbol)
  beforeMatch = True
  with open(filename, 'r') as f:
    for line in f:
        match = checkMatch(symbol, l, line)
        if beforeMatch and match:
            beforeMatch = False
            out.append(formatLineData(line[:-1].split(",")))
        elif not beforeMatch and match:
            out.append(formatLineData(line[:-1].split(",")))
        elif not beforeMatch and not match:
            break
  return out

(这段代码有几个辅助函数,checkMatch和formatLineData,我将在下面展示。)然后,还有另一个名为getDataColumn的函数,它以正确的天数获取我想要的列:

def getDataColumn(symbol, col=12, numDays=100, changeRateTransform=False):
  dataset = getData(symbol)
  if not changeRateTransform:
    column = [day[col] for day in dataset[-numDays:]]
  else:
    n = len(dataset)
    column = [(dataset[i][col] - dataset[i-1][col])/dataset[i-1][col] for i in range(n - numDays, n)]
  return column

(如果为True,则changeRateTransform将原始数字转换为每日变化率数字。)帮助函数:

def checkMatch(symbol, symbolLength, line):
  out = False
  if line[:symbolLength+1] == symbol + ",":
    out = True
  return out

def formatLineData(lineData):
  out = [lineData[0]]
  out.append(datetime.strptime(lineData[1], '%Y-%m-%d').date())
  out += [float(d) for d in lineData[2:6]]
  out += [int(float(d)) for d in lineData[6:9]]
  out += [float(d) for d in lineData[9:13]]
  out.append(int(float(lineData[13])))
  return out

有没有人对我的代码的哪些部分运行缓慢以及如何使其表现更好有任何见解?我不能做那种我想做的分析而不加快速度。

编辑: 为了回应这些评论,我对代码进行了一些更改,以便利用csv模块中的现有方法:

def getData(symbol, database):
  out = ["Symbol","Date","Open","High","Low","Close","Volume","Dividend",
         "Split","Adj_Open","Adj_High","Adj_Low","Adj_Close","Adj_Volume"]
  l = len(symbol)
  beforeMatch = True
  with open(database, 'r') as f:
    databaseReader = csv.reader(f, delimiter=",")
    for row in databaseReader:
        match = (row[0] == symbol)
        if beforeMatch and match:
            beforeMatch = False
            out.append(formatLineData(row))
        elif not beforeMatch and match:
            out.append(formatLineData(row))
        elif not beforeMatch and not match:
            break
  return out

def getDataColumn(dataset, col=12, numDays=100, changeRateTransform=False):
  if not changeRateTransform:
    out = [day[col] for day in dataset[-numDays:]]
  else:
    n = len(dataset)
    out = [(dataset[i][col] - dataset[i-1][col])/dataset[i-1][col] for i in range(n - numDays, n)]
  return out

使用csv.reader类时性能更差。我测试了两种股票,AMZN(靠近文件顶部)和ZNGA(靠近文件底部)。使用原始方法,运行时间分别为0.99秒和18.37秒。利用csv模块的新方法,运行时间分别为3.04秒和64.94秒。两者都返回正确的结果。

我的想法是,寻找股票的时间比解析更多。如果我在文件A中的第一个库存中尝试这些方法,则这些方法都会在大约0.12秒内运行。

3 个答案:

答案 0 :(得分:3)

当您要对同一数据集进行大量分析时,务实的方法是将其全部读入数据库。它是为快速查询而制作的; CSV不是。例如,使用sqlite命令行工具,可以直接从CSV导入。然后在(Symbol, Date)上添加一个索引,查找几乎是即时的。

如果由于某种原因不可行,例如因为新文件可能随时进入而您在开始分析它们之前无法承担准备时间,那么您将不得不直接处理CSV问题。 ,这是我的其余部分将重点关注的内容。但请记住,这是一种平衡行为。要么你预付了很多钱,要么每次查询都要多付一点钱。最终,对于一些查询,预先支付会更便宜。

优化是关于最大化未完成的工作量。在这种情况下,使用生成器和内置的csv模块对此没有多大帮助。您仍然会阅读整个文件并解析所有文件,至少是换行符。有了这么多数据,就不行了。

解析需要阅读,所以你必须首先找到解决方法。将CSV格式的所有复杂性留给专用模块的最佳做法在它们无法提供您想要的性能时没有任何意义。必须做一些作弊,但要尽可能少。在这种情况下,我认为可以安全地假设新行的开头可以标识为b'\n"AMZN",'(坚持您的示例)。是的,二进制在这里,因为记住:还没有解析。您可以从头开始扫描文件 as binary ,直到找到第一行。从那里读取你需要的行数,以正确的方式解码和解析它们等。不需要在那里进行优化,因为与你没有做的成千上万条不相关的行相比,100行无需担心为...工作。

删除所有解析会给你带来很多好处,但读数也需要优化。不要先将整个文件加载到内存中,并尽可能跳过尽可能多的Python层。使用mmap可让操作系统透明地决定将内容加载到内存中,并让您直接处理数据。

如果符号接近结尾,您仍然可能正在阅读整个文件。这是一个线性搜索,这意味着它所花费的时间与文件中的行数成线性比例。你可以做得更好。因为文件是排序的,所以您可以改进函数来执行一种二进制搜索。将采取的步骤数(步骤读取一行)接近行数的二进制对数。换句话说:您可以将文件分成两个(几乎)相同大小的部分的次数。当有一百万行时,这相差五个数量级!

以下是我提出的内容,基于Python自己的bisect_left,并采用一些措施来解释您的“值”跨越多个索引的事实:

import csv
from itertools import islice
import mmap

def iter_symbol_lines(f, symbol):
    # How to recognize the start of a line of interest
    ident = b'"' + symbol.encode() + b'",'
    # The memory-mapped file
    mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
    # Skip the header
    mm.readline()
    # The inclusive lower bound of the byte range we're still interested in
    lo = mm.tell()
    # The exclusive upper bound of the byte range we're still interested in
    hi = mm.size()
    # As long as the range isn't empty
    while lo < hi:
        # Find the position of the beginning of a line near the middle of the range
        mid = mm.rfind(b'\n', 0, (lo+hi)//2) + 1
        # Go to that position
        mm.seek(mid)
        # Is it a line that comes before lines we're interested in?
        if mm.readline() < ident:
            # If so, ignore everything up to right after this line
            lo = mm.tell()
        else:
            # Otherwise, ignore everything from right before this line
            hi = mid
    # We found where the first line of interest would be expected; go there
    mm.seek(lo)
    while True:
        line = mm.readline()
        if not line.startswith(ident):
            break
        yield line.decode()

with open(filename) as f:
    r = csv.reader(islice(iter_symbol_lines(f, 'AMZN'), 10))
    for line in r:
        print(line)

不保证此代码;我没有太注意边缘情况,我无法测试你的文件(任何),所以认为它是一个概念证明。然而,速度很快 - 想想SSD上几十毫秒!

答案 1 :(得分:2)

所以我有一个替代解决方案,我自己运行和测试,以及我在Quandl上获得的示例数据集,它似乎具有所有相同的标题和类似数据。 (假设我没有误解你试图达到的最终结果)。

我有一个这样的命令行工具,我们的工程师之一为我们解析了大量的csvs - 因为我每天处理荒谬的数据 - 它是开源的,你可以在这里得到它:{{ 3}}

我已经为它编写了短bash脚本,以防你不想管道命令,但它也支持管道传输。

运行以下短脚本的命令遵循一个非常简单的约定:

bash tickers.sh wikiprices.csv 'AMZN' '2016-12-\d+|2016-11-\d+'

#!/bin/bash


dates="$3"
cat "$1" \
  | gocsv filter --columns 'ticker' --regex "$2" \
  | gocsv filter --columns 'date' --regex "$dates" > "$2"'-out.csv'
  • 自动收报机和日期的两个参数都是正则表达式
  • 您可以根据需要在同一个正则表达式中添加任意数量的变体,并将它们分隔为|
  • 因此,如果您想要AMZN和MSFT,那么您只需将其修改为:AMZN|MSFT

  • 我做了一些与日期非常相似的事情 - 但我只限制我的样本运行到本月或上个月的任何日期。

最终结果

开始数据:

myusername$ gocsv dims wikiprices.csv    
Dimensions:
  Rows: 23946
  Columns: 14

myusername$ bash tickers.sh wikiprices.csv 'AMZN|MSFT' '2016-12-\d+'

myusername$ gocsv dims AMZN|MSFT-out.csv
Dimensions:
  Rows: 24
  Columns: 14

以下是一个示例,其中我仅限于那两个代码,然后仅限于12月:

https://github.com/DataFoxCo/gocsv

Voila - 在几秒钟内您就可以保存第二个文件,但不包含您关心的数据。

gocsv程序顺便提供了很好的文档 - 还有很多其他功能,例如基本上以任何比例运行vlookup(这是激发创建者制作工具的原因)

答案 2 :(得分:1)

除了使用csv.reader之外,我认为使用itertools.groupby会加快寻找想要的部分,所以实际的迭代看起来像这样:

import csv
from itertools import groupby 
from operator import itemgetter #for the keyfunc for groupby

def getData(wanted_symbol, filename):
    with open(filename) as file:
        reader = csv.reader(file)
        #so each line in reader is basically line[:-1].split(",") from the plain file
        for symb, lines in groupby(reader, itemgetter(0)):
            #so here symb is the symbol at the start of each line of lines
            #and lines is the lines that all have that symbol in common
            if symb != wanted_symbol:
                continue #skip this whole section if it has a different symbol
            for line in lines:
                #here we have each line as a list of fields
                #for only the lines that have `wanted_symbol` as the first element
                <DO STUFF HERE>

所以在<DO STUFF HERE>的空间中,您可以让out.append(formatLineData(line))执行当前代码的操作,但该函数的代码有很多不必要的切片和+=运算符认为列表相当昂贵(可能是错误的),另一种可以应用转换的方法是列出所有转换:

def conv_date(date_str):
    return datetime.strptime(date_str, '%Y-%m-%d').date()

#the conversions applied to each element (taken from original formatLineData)
castings = [str, conv_date,             #0, 1
            float, float, float, float, #2:6
            int, int, int,              #6:9
            float, float, float, float, #9:13
            int]                        #13

然后使用zip将这些应用于列表推导中一行中的每个字段:

 [conv(val) for conv, val in zip(castings, line)]

所以你可以用<DO STUFF HERE>替换out.append

我还想知道切换groupbyreader的顺序是否会更好,因为您不需要将大部分文件解析为csv,只是您实际迭代的部分所以你可以使用一个keyfunc,它只分隔字符串的第一个字段

def getData(wanted_symbol, filename):
    out = [] #why are you starting this with strings in it?
    def checkMatch(line): #define the function to only take the line
        #this would be the keyfunc for groupby in this example
        return line.split(",",1)[0] #only split once, return the first element

    with open(filename) as file:
        for symb, lines in groupby(file,checkMatch):
            #so here symb is the symbol at the start of each line of lines
            if symb != wanted_symbol:
                continue #skip this whole section if it has a different symbol
            for line in csv.reader(lines):
                out.append(  [typ(val) for typ,val in zip(castings,line)]  )
    return out