6 votes

Extraction rapide de morceaux de lignes à partir d'un grand fichier CSV

Je dispose d'un grand fichier CSV rempli de données relatives aux actions et formaté comme tel :

Symbole du téléscripteur, date, [quelques variables...].

Chaque ligne commence donc par le symbole (comme "AMZN"), puis la date, et enfin 12 variables liées au prix ou au volume à la date sélectionnée. Il y a environ 10 000 titres différents représentés dans ce fichier et j'ai une ligne pour chaque jour où l'action a été échangée publiquement pour chacun d'entre eux. Le fichier est d'abord classé par ordre alphabétique de symbole de téléscripteur, puis par ordre chronologique de date. L'ensemble du fichier pèse environ 3,3 Go.

Le type de tâche que je veux résoudre serait d'être capable d'extraire le plus récent n lignes de données pour un symbole de téléscripteur donné par rapport à la date actuelle. J'ai du code qui fait cela, mais d'après mes observations, cela semble prendre, en moyenne, environ 8-10 secondes par extraction (tous les tests ont extrait 100 lignes).

J'ai des fonctions que j'aimerais exécuter et qui me demandent de saisir de tels morceaux pour des centaines ou des milliers de symboles, et j'aimerais vraiment réduire ce temps. Mon code est inefficace, mais je ne sais pas comment le rendre plus rapide.

D'abord, j'ai une fonction appelée 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

(Ce code comporte quelques fonctions d'aide, checkMatch et formatLineData, que je montrerai ci-dessous). Ensuite, il y a une autre fonction appelée getDataColumn qui obtient la colonne que je veux avec le nombre correct de jours représentés :

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

(changeRateTransform convertit les nombres bruts en nombres de taux de changement quotidien si c'est vrai). Les fonctions d'aide :

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

Quelqu'un a-t-il une idée des parties de mon code qui sont lentes et de la façon dont je peux améliorer les performances ? Je ne peux pas faire le genre d'analyse que je veux faire sans accélérer le processus.


EDIT : En réponse aux commentaires, j'ai apporté quelques modifications au code afin d'utiliser les méthodes existantes dans le module 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

Les performances étaient moins bonnes en utilisant la classe csv.reader. J'ai testé sur deux actions, AMZN (en haut du fichier) et ZNGA (en bas du fichier). Avec la méthode originale, les temps d'exécution étaient de 0,99 seconde et 18,37 secondes, respectivement. Avec la nouvelle méthode utilisant le module csv, les temps d'exécution étaient respectivement de 3,04 secondes et 64,94 secondes. Les deux méthodes donnent des résultats corrects.

Je pense que le temps est davantage consacré à la recherche du stock qu'à l'analyse. Si j'essaie ces méthodes sur la première action du fichier, A, les deux méthodes s'exécutent en 0,12 seconde environ.

3voto

Thijs van Dien Points 1771

Lorsque vous allez faire beaucoup d'analyses sur le même ensemble de données, l'approche pragmatique serait de tout lire dans une base de données. Celle-ci est faite pour les requêtes rapides, ce qui n'est pas le cas du CSV. Utilisez les outils de ligne de commande sqlite, par exemple, qui peuvent directement importer à partir de CSV. Ajoutez ensuite un index unique sur (Symbol, Date) et les recherches seront pratiquement instantanées.

Si, pour une raison ou une autre, ce n'est pas possible, par exemple parce que de nouveaux fichiers peuvent arriver à tout moment et que vous ne pouvez pas vous permettre le temps de préparation avant de commencer votre analyse de ces fichiers, vous devrez faire de votre mieux pour traiter directement les CSV, ce sur quoi portera le reste de ma réponse. N'oubliez pas qu'il s'agit d'une question d'équilibre. Soit vous payez beaucoup d'avance, soit vous payez un peu plus pour chaque consultation. Au final, pour un certain nombre de consultations, il aurait été moins cher de payer d'avance.

L'optimisation consiste à maximiser la quantité de travail non effectué. En utilisant les générateurs et la fonction intégrée csv Le module ne va pas beaucoup aider dans ce cas. Vous devrez toujours lire l'ensemble du fichier et l'analyser, au moins pour les sauts de ligne. Avec une telle quantité de données, c'est impossible.

L'analyse syntaxique nécessite une lecture, vous devrez donc d'abord trouver un moyen de la contourner. Les meilleures pratiques consistant à laisser toutes les subtilités du format CSV au module spécialisé n'ont aucun sens lorsqu'elles ne peuvent pas vous donner les performances que vous souhaitez. Il faut tricher un peu, mais le moins possible. Dans ce cas, je suppose qu'il est sûr de supposer que le début d'une nouvelle ligne peut être identifié comme b'\n"AMZN",' (en restant dans votre exemple). Oui, binaire ici, car rappelez-vous : pas encore d'analyse syntaxique. Vous pourriez analyser le fichier comme binaire depuis le début jusqu'à ce que vous trouviez la première ligne. À partir de là, lisez le nombre de lignes dont vous avez besoin, décodez-les et analysez-les de la manière appropriée, etc. Il n'y a pas besoin d'optimisation, car 100 lignes ne sont pas un problème comparé aux centaines de milliers de lignes non pertinentes pour lesquelles vous ne faites pas ce travail.

L'abandon de tout ce travail d'analyse vous fait gagner beaucoup, mais la lecture doit également être optimisée. Ne chargez pas d'abord le fichier entier en mémoire et sautez autant de couches de Python que possible. Utilisation de mmap laisse le système d'exploitation décider de ce qu'il faut charger en mémoire de manière transparente et vous permet de travailler directement avec les données.

Pourtant, vous lisez potentiellement tout le fichier, si le symbole se trouve vers la fin. C'est une recherche linéaire, ce qui signifie que le temps qu'elle prend est linéairement proportionnel au nombre de lignes du fichier. Vous pouvez cependant faire mieux. Comme le fichier est trié, vous pouvez améliorer la fonction pour effectuer une sorte de recherche binaire. Le nombre de pas que cela prendra (où un pas est la lecture d'une ligne) est proche du logarithme binaire du nombre de lignes. En d'autres termes : le nombre de fois que vous pouvez diviser votre fichier en deux parties de taille (presque) égale. Quand il y a un million de lignes, c'est une différence de cinq ordres de grandeur !

Voici ce que j'ai trouvé, en me basant sur la méthode de Python bisect_left avec certaines mesures pour tenir compte du fait que vos "valeurs" couvrent plus d'un indice :

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)

Ce code n'offre aucune garantie ; je n'ai pas prêté beaucoup d'attention aux cas limites, et je n'ai pas pu le tester avec (aucun) de vos fichiers, alors considérez-le comme une preuve de concept. C'est très rapide, cependant - pensez à des dizaines de millisecondes sur un SSD !

2voto

Aurielle Perlmann Points 3570

J'ai donc une solution alternative que j'ai exécutée et testée moi-même avec un échantillon de données que j'ai obtenu sur Quandl et qui semble avoir les mêmes en-têtes et des données similaires. (En supposant que je n'ai pas mal compris le résultat final que vous essayez d'obtenir).

J'ai cet outil en ligne de commande qu'un de nos ingénieurs a construit pour nous afin d'analyser des csv massifs - puisque je traite des quantités absurdes de données au quotidien - il est open sourced et vous pouvez l'obtenir ici : https://github.com/DataFoxCo/gocsv

J'ai aussi déjà écrit le court bash script pour lui au cas où vous ne voulez pas pipeliner les commandes mais il supporte aussi le pipelining.

La commande pour exécuter le court script suivant suit une convention super simple :

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'
  • les deux arguments pour le ticker et pour les dates sont des regexes

  • Vous pouvez ajouter autant de variantes que vous le souhaitez dans cette unique regex, en les séparant par | .

  • Donc, si vous voulez AMZN et MSFT, il vous suffit de le modifier comme suit : AMZN|MSFT

  • J'ai fait quelque chose de très similaire avec les dates - mais j'ai limité mon échantillonnage à toutes les dates de ce mois ou du mois dernier.

Résultat final

Données de départ :

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

Voici un exemple où je me suis limité à ces deux tickers et au mois de décembre uniquement :

enter image description here

Voilà, en quelques secondes, vous avez un deuxième fichier enregistré sans les données qui vous intéressent.

Le programme gocsv dispose d'une excellente documentation - et d'une tonne d'autres fonctions, par exemple l'exécution d'un vlookup à n'importe quelle échelle (c'est ce qui a inspiré le créateur de cet outil).

1voto

En plus d'utiliser csv.reader Je pense qu'utiliser itertools.groupby accélérerait la recherche des sections recherchées, de sorte que l'itération actuelle pourrait ressembler à quelque chose comme ceci :

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>

donc dans l'espace de <DO STUFF HERE> vous pourriez avoir le out.append(formatLineData(line)) pour faire ce que votre code actuel fait mais le code de cette fonction a beaucoup de découpage inutile et de += opérateurs qui, je pense, sont assez chers pour les listes (je peux me tromper), une autre façon d'appliquer les conversions est d'avoir une liste de toutes les conversions :

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

puis utiliser zip pour les appliquer à chaque champ d'une ligne dans une compréhension de liste :

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

Vous devez donc remplacer <DO STUFF HERE> con out.append avec cette compréhension.


Je me demanderais aussi si le fait de changer l'ordre de groupby y reader serait mieux puisque vous n'avez pas besoin d'analyser la plus grande partie du fichier en tant que csv, seulement les parties sur lesquelles vous itérez réellement. Vous pourriez donc utiliser un keyfunc qui ne sépare que le premier champ de la chaîne.

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

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X