web-dev-qa-db-fra.com

Accélération des pandas.DataFrame.to_sql avec fast_executemany de pyODBC

J'aimerais envoyer un grand pandas.DataFrame à un serveur distant exécutant MS SQL. Pour ce faire, convertissez un objet data_frame en une liste de n-uplets, puis envoyez-le avec la fonction executemany() de pyODBC. Ca fait plutot comme ca:

 import pyodbc as pdb

 list_of_tuples = convert_df(data_frame)

 connection = pdb.connect(cnxn_str)

 cursor = connection.cursor()
 cursor.fast_executemany = True
 cursor.executemany(sql_statement, list_of_tuples)
 connection.commit()

 cursor.close()
 connection.close()

J'ai alors commencé à me demander si les choses peuvent être accélérées (ou au moins plus lisibles) en utilisant la méthode data_frame.to_sql(). Je suis venu avec la solution suivante:

 import sqlalchemy as sa

 engine = sa.create_engine("mssql+pyodbc:///?odbc_connect=%s" % cnxn_str)
 data_frame.to_sql(table_name, engine, index=False)

Maintenant, le code est plus lisible, mais le téléchargement est au moins 150 fois plus lent ...

Existe-t-il un moyen de retourner le fast_executemany lors de l'utilisation de SQLAlchemy?

J'utilise pandas-0.20.3, pyODBC-4.0.21 et sqlalchemy-1.1.13.

34
J.K.

Après avoir contacté les développeurs de SQLAlchemy, un moyen de résoudre ce problème est apparu. Un grand merci à eux pour leur excellent travail!

Il faut utiliser un événement d'exécution du curseur et vérifier si l'indicateur executemany a été levé. Si tel est le cas, activez l'option fast_executemany. Par exemple:

from sqlalchemy import event

@event.listens_for(engine, 'before_cursor_execute')
def receive_before_cursor_execute(conn, cursor, statement, params, context, executemany):
    if executemany:
        cursor.fast_executemany = True

Plus d'informations sur les événements d'exécution peuvent être trouvés ici .


UPDATE: La prise en charge de fast_executemany de pyodbc a été ajoutée à SQLAlchemy 1.3. , donc bidouille n'est plus nécessaire.

19
J.K.

EDIT (2019-03-08): Gord Thompson a commenté ci-dessous de bonnes nouvelles des journaux de mise à jour de sqlalchemy: depuis SQLAlchemy 1.3.0, publiée le 2019-03-04, sqlalchemy prend désormais en charge engine = create_engine(sqlalchemy_url, fast_executemany=True) pour le dialecte mssql+pyodbc. C'est-à-dire qu'il n'est plus nécessaire de définir une fonction et d'utiliser @event.listens_for(engine, 'before_cursor_execute') Ce qui signifie que la fonction ci-dessous peut être supprimée et que seul l'indicateur doit être défini dans l'instruction create_engine - tout en conservant l'accélération. .

Message original:

Vient de créer un compte pour poster ceci. Je voulais commenter sous le fil ci-dessus car c'est un suivi de la réponse déjà fournie. La solution ci-dessus a fonctionné pour moi avec le pilote SQL version 17 sur un stockage SQL Microsft écrit à partir d’une installation Ubuntu.

Le code complet que j'ai utilisé pour accélérer considérablement les choses (parler plus de 100 fois plus vite) est présenté ci-dessous. Il s'agit d'un extrait clé en main à condition que vous modifiiez la chaîne de connexion avec vos informations pertinentes. Pour l'affiche ci-dessus, merci beaucoup pour la solution car je cherchais déjà pas mal de temps pour cela.

import pandas as pd
import numpy as np
import time
from sqlalchemy import create_engine, event
from urllib.parse import quote_plus


conn =  "DRIVER={ODBC Driver 17 for SQL Server};SERVER=IP_ADDRESS;DATABASE=DataLake;UID=USER;PWD=PASS"
quoted = quote_plus(conn)
new_con = 'mssql+pyodbc:///?odbc_connect={}'.format(quoted)
engine = create_engine(new_con)


@event.listens_for(engine, 'before_cursor_execute')
def receive_before_cursor_execute(conn, cursor, statement, params, context, executemany):
    print("FUNC call")
    if executemany:
        cursor.fast_executemany = True


table_name = 'fast_executemany_test'
df = pd.DataFrame(np.random.random((10**4, 100)))


s = time.time()
df.to_sql(table_name, engine, if_exists = 'replace', chunksize = None)
print(time.time() - s)

Sur la base des commentaires ci-dessous, je souhaitais prendre un peu de temps pour expliquer certaines limitations concernant l'implémentation de pandas to_sql et la manière dont la requête est traitée. Il y a 2 choses qui pourraient causer la MemoryError être soulevée autant que je sache:

1) En supposant que vous écrivez sur un stockage SQL distant. Lorsque vous essayez d'écrire un grand pandas DataFrame avec la méthode to_sql, il convertit l'ensemble du cadre de données en une liste de valeurs. Cette transformation prend beaucoup plus de RAM que le DataFrame d'origine (en plus, l'ancien DataFrame restant toujours présent dans la RAM). Cette liste est fournie à l'appel final executemany de votre connecteur ODBC. Je pense que le connecteur ODBC a quelques problèmes à gérer des requêtes aussi volumineuses. Un moyen de résoudre ce problème consiste à fournir à la méthode to_sql un argument de type chunksize (10 ** 5 semble être optimal, donnant une vitesse d'écriture d'environ 600 mbit/s (!) Sur une application MSSQL Storage à 2 processeurs et 7 Go de RAM d'Azure - ne peut pas recommander Azure btw). Donc, la première limitation, la taille de la requête, peut être contournée en fournissant un argument chunksize. Toutefois, cela ne vous permettra pas d’écrire une trame de données de la taille 10 ** 7 ou plus, (du moins pas sur le VM que je travaille avec une mémoire vive de ~ 55 Go), numéro 2. .

Vous pouvez contourner ce problème en décomposant le DataFrame avec np.split (10 morceaux de DataFrame de la taille 6 ** 6). Ceux-ci peuvent être écrits de manière itérative. Je vais essayer de faire une demande de tirage quand une solution est prête pour la méthode to_sql au cœur de pandas lui-même, de sorte que vous n'ayez pas à le faire à chaque fois. Quoi qu'il en soit, j'ai fini par écrire une fonction similaire (pas clé en main) à ce qui suit:

import pandas as pd
import numpy as np

def write_df_to_sql(df, **kwargs):
    chunks = np.split(df, df.shape()[0] / 10**6)
    for chunk in chunks:
        chunk.to_sql(**kwargs)
    return True

Un exemple plus complet de l'extrait ci-dessus peut être consulté ici: https://gitlab.com/timelord/timelord/blob/master/timelord/utils/connector.py

C’est une classe que j’ai écrite et qui incorpore le correctif et allège les frais généraux liés à la configuration de connexions avec SQL. Encore faut-il écrire de la documentation. J'avais aussi l'intention de contribuer le correctif à pandas lui-même, mais je n'ai pas encore trouvé le moyen idéal de le faire.

J'espère que ça aide.

45
hetspookjee

Je souhaitais simplement publier cet exemple complet en tant qu'option supplémentaire performante pour ceux qui peuvent utiliser la nouvelle bibliothèque turbodbc: http://turbodbc.readthedocs.io/en/latest/

Il existe clairement de nombreuses options de flux entre pandas .to_sql (), déclanchant fast_executemany via sqlalchemy, utilisant pyodbc directement avec tuples/listes/etc., ou même essayant BULK UPLOAD avec des fichiers à plat.

Espérons que les éléments suivants rendront la vie un peu plus agréable à mesure que la fonctionnalité évoluera dans le projet actuel pandas ou inclura quelque chose comme l’intégration de turbodbc dans le futur.

import pandas as pd
import numpy as np
from turbodbc import connect, make_options
from io import StringIO

test_data = '''id,transaction_dt,units,measures
               1,2018-01-01,4,30.5
               1,2018-01-03,4,26.3
               2,2018-01-01,3,12.7
               2,2018-01-03,3,8.8'''

df_test = pd.read_csv(StringIO(test_data), sep=',')
df_test['transaction_dt'] = pd.to_datetime(df_test['transaction_dt'])

options = make_options(parameter_sets_to_buffer=1000)
conn = connect(driver='{SQL Server}', server='server_nm', database='db_nm', turbodbc_options=options)

test_query = '''DROP TABLE IF EXISTS [db_name].[schema].[test]

                CREATE TABLE [db_name].[schema].[test]
                (
                    id int NULL,
                    transaction_dt datetime NULL,
                    units int NULL,
                    measures float NULL
                )

                INSERT INTO [db_name].[schema].[test] (id,transaction_dt,units,measures)
                VALUES (?,?,?,?) '''

cursor.executemanycolumns(test_query, [df_test['id'].values, df_test['transaction_dt'].values, df_test['units'].values, df_test['measures'].values]

turbodbc devrait être TRÈS rapide dans de nombreux cas d'utilisation (en particulier avec les tableaux numpy). Veuillez observer à quel point il est simple de passer directement les tableaux numpy sous-jacents des colonnes de la structure de données à la requête. Je pense également que cela permet d’empêcher la création d’objets intermédiaires qui augmentent considérablement la consommation de mémoire. J'espère que c'est utile!

9
Pylander

J'ai rencontré le même problème mais en utilisant PostgreSQL. Ils viennent maintenant de libérer version 0.24.0 de pandas et il y a un nouveau paramètre dans la fonction to_sql appelé method qui a résolu mon problème.

from sqlalchemy import create_engine

engine = create_engine(your_options)
data_frame.to_sql(table_name, engine, method="multi")

La vitesse de téléchargement est 100x plus rapide pour moi. Je vous recommande également de régler le paramètre chunksize si vous souhaitez envoyer beaucoup de données.

9
Emmanuel

Il semble que Pandas 0.23.0 et 0.24.0 tilise des inserts de valeurs multiples avec PyODBC, ce qui empêche toute exécution rapide - une seule instruction INSERT ... VALUES ... est émise par bloc. . Les fragments d'insertion de valeurs multiples constituent une amélioration par rapport à l'ancien système d'exécution lent par défaut, mais au moins dans les tests simples, la méthode d'exécution rapide prévaut toujours, sans parler du besoin de calculs manuels chunksize, comme c'est le cas pour les insertions de valeurs multiples. Forcer l'ancien comportement peut être fait par monkeypatching, si aucune option de configuration n'est fournie à l'avenir:

import pandas.io.sql

def insert_statement(self, data, conn):
    return self.table.insert(), data

pandas.io.sql.SQLTable.insert_statement = insert_statement

Le futur est ici et au moins dans la branche master, la méthode d’insertion peut être contrôlée à l’aide du mot-clé argument method= de to_sql() . La valeur par défaut est None, ce qui force la méthode executemany. Passer method='multi' entraîne l'utilisation de l'insertion de valeurs multiples. Il peut même être utilisé pour implémenter des approches spécifiques aux SGBD, telles que Postgresql COPY.

4
Ilja Everilä

Comme l'a souligné @Pylander

Turbodbc est de loin le meilleur choix pour l’ingestion de données!

J'étais tellement excité à propos de cela que j'ai écrit un "blog" dessus sur mon github et mon support: veuillez vérifier https://medium.com/@erickfis/etl-process-with-turbodbc-1d19ed71510e

pour un exemple de travail et une comparaison avec pandas.to_sql

Longue histoire courte,

avec turbodbc j'ai 10000 lignes (77 colonnes) en 3 secondes

avec pandas.to_sql j'ai les mêmes 10000 lignes (77 colonnes) en 198 secondes ...

Et voici ce que je fais en détail

Les importations:

import sqlalchemy
import pandas as pd
import numpy as np
import turbodbc
import time

Charger et traiter des données - Remplacez le vôtre par sample.pkl:

df = pd.read_pickle('sample.pkl')

df.columns = df.columns.str.strip()  # remove white spaces around column names
df = df.applymap(str.strip) # remove white spaces around values
df = df.replace('', np.nan)  # map nans, to drop NAs rows and columns later
df = df.dropna(how='all', axis=0)  # remove rows containing only NAs
df = df.dropna(how='all', axis=1)  # remove columns containing only NAs
df = df.replace(np.nan, 'NA')  # turbodbc hates null values...

Créer la table en utilisant sqlAlchemy

Malheureusement, turbodbc nécessite beaucoup de temps système et de main-d’œuvre manuelle, pour la création des tables et l’insertion de données.

Heureusement, Python est une pure joie et nous pouvons automatiser ce processus d’écriture de code SQL.

La première étape consiste à créer la table qui recevra nos données. Cependant, créer la table manuellement en écrivant du code SQL peut être problématique si votre table contient plus de quelques colonnes. Dans mon cas, très souvent, les tables ont 240 colonnes!

C'est là que sqlAlchemy et pandas peuvent toujours nous aider: pandas est mauvais pour écrire un grand nombre de lignes (10000 dans cet exemple), mais qu'en est-il seulement de 6 lignes, la tête de la table? De cette façon, nous automatisons le processus de création des tables.

Créer une connexion sqlAlchemy:

mydb = 'someDB'

def make_con(db):
    """Connect to a specified db."""
    database_connection = sqlalchemy.create_engine(
        'mssql+pymssql://{0}:{1}@{2}/{3}'.format(
            myuser, mypassword,
            myhost, db
            )
        )
    return database_connection

pd_connection = make_con(mydb)

Créer une table sur SQL Server

Utiliser pandas + sqlAlchemy, mais juste pour préparer de la place pour la turbodbc comme mentionné précédemment. Veuillez noter que df.head () ici: nous utilisons pandas + sqlAlchemy pour n’insérer que 6 lignes de nos données. Cela fonctionnera assez vite et est en train d'être fait pour automatiser la création de la table.

table = 'testing'
df.head().to_sql(table, con=pd_connection, index=False)

Maintenant que la table est déjà en place, passons au sérieux.

Connexion Turbodbc:

def turbo_conn(mydb):
    """Connect to a specified db - turbo."""
    database_connection = turbodbc.connect(
                                            driver='ODBC Driver 17 for SQL Server',
                                            server=myhost,
                                            database=mydb,
                                            uid=myuser,
                                            pwd=mypassword
                                        )
    return database_connection

Préparation des commandes SQL et des données pour turbodbc. Automatisons cette création de code en cours de création:

def turbo_write(mydb, df, table):
    """Use turbodbc to insert data into sql."""
    start = time.time()
    # preparing columns
    colunas = '('
    colunas += ', '.join(df.columns)
    colunas += ')'

    # preparing value place holders
    val_place_holder = ['?' for col in df.columns]
    sql_val = '('
    sql_val += ', '.join(val_place_holder)
    sql_val += ')'

    # writing sql query for turbodbc
    sql = f"""
    INSERT INTO {mydb}.dbo.{table} {colunas}
    VALUES {sql_val}
    """

    # writing array of values for turbodbc
    valores_df = [df[col].values for col in df.columns]

    # cleans the previous head insert
    with connection.cursor() as cursor:
        cursor.execute(f"delete from {mydb}.dbo.{table}")
        connection.commit()

    # inserts data, for real
    with connection.cursor() as cursor:
        try:
            cursor.executemanycolumns(sql, valores_df)
            connection.commit()
        except Exception:
            connection.rollback()
            print('something went wrong')

    stop = time.time() - start
    return print(f'finished in {stop} seconds')

Écrire des données en utilisant turbodbc - J'ai 10000 lignes (77 colonnes) en 3 secondes:

turbo_write(mydb, df.sample(10000), table)

Comparaison de méthodes Pandas - J'ai les mêmes 10000 lignes (77 colonnes) en 198 secondes…

table = 'pd_testing'

def pandas_comparisson(df, table):
    """Load data using pandas."""
    start = time.time()
    df.to_sql(table, con=pd_connection, index=False)
    stop = time.time() - start
    return print(f'finished in {stop} seconds')

pandas_comparisson(df.sample(10000), table)

Environnement et conditions

Python 3.6.7 :: Anaconda, Inc.
TURBODBC version ‘3.0.0’
sqlAlchemy version ‘1.2.12’
pandas version ‘0.23.4’
Microsoft SQL Server 2014
user with bulk operations privileges

S'il vous plaît vérifier https://erickfis.github.io/loose-code/ pour les mises à jour dans ce code!

3
erickfis

Performances SQL Server INSERT: pyodbc vs turbodbc

Lorsque vous utilisez to_sql pour télécharger un pandas DataFrame sur SQL Server, turbodbc sera définitivement plus rapide que pyodbc sans fast_executemany. Cependant, avec fast_executemany activé pour pyodbc, les deux approches donnent essentiellement les mêmes performances.

Environnements de test:

[venv1_pyodbc]
pyodbc 2.0.25

[venv2_turbodbc]
turbodbc 3.0.0
sqlalchemy-turbodbc 0.1.0

[commun aux deux]
Python 3.6.4 64 bits sous Windows
SQLAlchemy 1.3.0b1
pandas 0.23.4
numpy 1.15.4

Code de test:

# for pyodbc
engine = create_engine('mssql+pyodbc://sa:whatever@SQL_panorama', fast_executemany=True)
# for turbodbc
# engine = create_engine('mssql+turbodbc://sa:whatever@SQL_panorama')

# test data
num_rows = 10000
num_cols = 100
df = pd.DataFrame(
    [[f'row{x:04}col{y:03}' for y in range(num_cols)] for x in range(num_rows)],
    columns=[f'col{y:03}' for y in range(num_cols)]
)

t0 = time.time()
df.to_sql("sqlalchemy_test", engine, if_exists='replace', index=None)
print(f"pandas wrote {num_rows} rows in {(time.time() - t0):0.1f} seconds")

Les tests ont été effectués douze (12) fois pour chaque environnement, en éliminant les meilleurs et les pires moments uniques pour chacun. Résultats (en secondes):

   rank  pyodbc  turbodbc
   ----  ------  --------
      1    22.8      27.5
      2    23.4      28.1
      3    24.6      28.2
      4    25.2      28.5
      5    25.7      29.3
      6    26.9      29.9
      7    27.0      31.4
      8    30.1      32.1
      9    33.6      32.5
     10    39.8      32.9
   ----  ------  --------
average    27.9      30.0
3
Gord Thompson

Je voulais juste ajouter à la réponse de @J.K.

Si vous utilisez cette approche:

@event.listens_for(engine, 'before_cursor_execute')
def receive_before_cursor_execute(conn, cursor, statement, params, context, executemany):
    if executemany:
        cursor.fast_executemany = True

Et vous obtenez cette erreur:

"sqlalchemy.exc.DBAPIError: (pyodbc.Error) ('HY010', '[HY010] [Microsoft] [Client SQL Server Native 11.0] Erreur de séquence de fonctions (0) (SQLParamData)') [SQL: 'INSERT INTO .. . (...) VALEURS (?,?) '] [Paramètres: ((..., ...), (..., ...)] (Contexte de cette erreur à: http : //sqlalche.me/e/dbapi ) "

Encodez vos valeurs de chaîne comme ceci: 'yourStringValue'.encode('ascii')

Cela résoudra votre problème.

2
Azamat Bekkhozha