87 votes

Suppression rapide de la ponctuation avec les pandas

Il s'agit d'un post avec réponse automatique. Je présente ci-dessous un problème courant dans le domaine de la PNL et propose quelques méthodes performantes pour le résoudre.

Il est souvent nécessaire d'enlever ponctuation pendant le nettoyage et le prétraitement du texte. La ponctuation est définie comme tout caractère dans string.punctuation :

>>> import string
string.punctuation
'!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'

Il s'agit d'un problème assez courant, qui a déjà été posé ad nauseam. La solution la plus idiomatique utilise pandas str.replace . Toutefois, pour les situations qui impliquent un lot de texte, il peut être nécessaire d'envisager une solution plus performante.

Quelles sont les bonnes alternatives performantes à str.replace lorsqu'il s'agit de centaines de milliers d'enregistrements ?

99voto

coldspeed Points 111053

Configuration

Pour les besoins de la démonstration, considérons ce DataFrame.

df = pd.DataFrame({'text':['a..b?!??', '%hgh&12','abc123!!!', '$$$1234']})
df
        text
0   a..b?!??
1    %hgh&12
2  abc123!!!
3    $$$1234

Ci-dessous, j'énumère les alternatives, une par une, par ordre croissant de performance

str.replace

Cette option est incluse pour établir la méthode par défaut comme référence pour comparer d'autres solutions plus performantes.

Cela utilise la fonction intégrée de pandas str.replace qui effectue un remplacement basé sur une expression rationnelle.

df['text'] = df['text'].str.replace(r'[^\w\s]+', '')

df
     text
0      ab
1   hgh12
2  abc123
3    1234

C'est très facile à coder, et c'est assez lisible, mais lent.


regex.sub

Cela implique l'utilisation du sub de la fonction re bibliothèque. Pré-compilez un motif regex pour plus de performance, et appelez regex.sub à l'intérieur d'une liste de compréhension. Convertir df['text'] à une liste au préalable si vous pouvez disposer d'un peu de mémoire, vous obtiendrez un petit gain de performance appréciable.

import re
p = re.compile(r'[^\w\s]+')
df['text'] = [p.sub('', x) for x in df['text'].tolist()]

df
     text
0      ab
1   hgh12
2  abc123
3    1234

Note : Si vos données contiennent des valeurs NaN, cette méthode (ainsi que la suivante) ne fonctionnera pas telle quelle. Voir la section sur " Autres considérations ".


str.translate

de python str.translate est implémentée en C, et est donc très rapide .

Voici comment cela fonctionne :

  1. D'abord, joignez toutes vos cordes ensemble pour former un énorme chaîne de caractères en utilisant un seul (ou plusieurs) caractère séparateur que vous choisir. Vous doit utilisez un caractère/sous-chaîne dont vous pouvez garantir qu'il n'appartient pas à vos données.
  2. Exécuter str.translate sur la grande chaîne, en supprimant la ponctuation (le séparateur de l'étape 1 exclu).
  3. Divisez la chaîne sur le séparateur qui a été utilisé pour joindre à l'étape 1. La liste résultante doit ont la même longueur que votre colonne initiale.

Ici, dans cet exemple, nous considérons le séparateur de tuyaux | . Si vos données contiennent le tuyau, vous devez alors choisir un autre séparateur.

import string

punct = '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{}~'   # `|` is not present here
transtab = str.maketrans(dict.fromkeys(punct, ''))

df['text'] = '|'.join(df['text'].tolist()).translate(transtab).split('|')

df
     text
0      ab
1   hgh12
2  abc123
3    1234

Performance

str.translate est le plus performant, et de loin. Notez que le graphique ci-dessous inclut une autre variante Series.str.translate de La réponse de MaxU .

(Il est intéressant de noter que j'ai refait l'opération une deuxième fois et que les résultats sont légèrement différents de ceux obtenus précédemment. Lors de la deuxième exécution, il semble re.sub l'emportait sur str.translate pour les très petites quantités de données). enter image description here

Il y a un risque inhérent à l'utilisation translate (en particulier, le problème de automatisation de le processus de décision concernant le séparateur à utiliser n'est pas trivial), mais le jeu en vaut la chandelle.


Autres considérations

Gestion des NaNs avec les méthodes de compréhension des listes ; Notez que cette méthode (et la suivante) ne fonctionnera que si vos données ne contiennent pas de NaN. Lorsque vous traitez les NaN, vous devez déterminer les indices des valeurs non nulles et ne remplacer que celles-ci. Essayez quelque chose comme ceci :

df = pd.DataFrame({'text': [
    'a..b?!??', np.nan, '%hgh&12','abc123!!!', '$$$1234', np.nan]})

idx = np.flatnonzero(df['text'].notna())
col_idx = df.columns.get_loc('text')
df.iloc[idx,col_idx] = [
    p.sub('', x) for x in df.iloc[idx,col_idx].tolist()]

df
     text
0      ab
1     NaN
2   hgh12
3  abc123
4    1234
5     NaN

Traiter les DataFrames ; Si vous avez affaire à des DataFrames, dans lesquelles chaque doit être remplacée, la procédure est simple :

v = pd.Series(df.values.ravel())
df[:] = translate(v).values.reshape(df.shape)

Ou,

v = df.stack()
v[:] = translate(v)
df = v.unstack()

Notez que le translate est définie ci-dessous avec le code de référence.

Chaque solution présente des inconvénients, de sorte que le choix de la solution la mieux adaptée à vos besoins dépendra de ce que vous êtes prêt à sacrifier. Deux considérations très courantes sont les performances (que nous avons déjà vues) et l'utilisation de la mémoire. str.translate est une solution gourmande en mémoire, à utiliser avec précaution.

Une autre considération est la complexité de votre regex. Parfois, vous voudrez supprimer tout ce qui n'est pas alphanumérique ou espace. Dans d'autres cas, vous devrez conserver certains caractères, tels que les traits d'union, les deux-points et les terminaisons de phrase. [.!?] . Le fait de les spécifier explicitement ajoute de la complexité à votre regex, ce qui peut à son tour avoir un impact sur les performances de ces solutions. Assurez-vous de tester ces solutions sur vos données avant de décider de leur utilisation.

Enfin, les caractères unicode seront supprimés avec cette solution. Vous pouvez modifier votre regex (si vous utilisez une solution basée sur le regex), ou simplement utiliser la solution suivante str.translate autrement.

Pour même plus (pour les grands N), jetez un coup d'œil à cette réponse par Paul Panzer .


Annexe

Fonctions

def pd_replace(df):
    return df.assign(text=df['text'].str.replace(r'[^\w\s]+', ''))

def re_sub(df):
    p = re.compile(r'[^\w\s]+')
    return df.assign(text=[p.sub('', x) for x in df['text'].tolist()])

def translate(df):
    punct = string.punctuation.replace('|', '')
    transtab = str.maketrans(dict.fromkeys(punct, ''))

    return df.assign(
        text='|'.join(df['text'].tolist()).translate(transtab).split('|')
    )

# MaxU's version (https://stackoverflow.com/a/50444659/4909087)
def pd_translate(df):
    punct = string.punctuation.replace('|', '')
    transtab = str.maketrans(dict.fromkeys(punct, ''))

    return df.assign(text=df['text'].str.translate(transtab))

Code d'évaluation comparative des performances

from timeit import timeit

import pandas as pd
import matplotlib.pyplot as plt

res = pd.DataFrame(
       index=['pd_replace', 're_sub', 'translate', 'pd_translate'],
       columns=[10, 50, 100, 500, 1000, 5000, 10000, 50000],
       dtype=float
)

for f in res.index: 
    for c in res.columns:
        l = ['a..b?!??', '%hgh&12','abc123!!!', '$$$1234'] * c
        df = pd.DataFrame({'text' : l})
        stmt = '{}(df)'.format(f)
        setp = 'from __main__ import df, {}'.format(f)
        res.at[f, c] = timeit(stmt, setp, number=30)

ax = res.div(res.min()).T.plot(loglog=True) 
ax.set_xlabel("N"); 
ax.set_ylabel("time (relative)");

plt.show()

37voto

Paul Panzer Points 30707

En utilisant numpy, nous pouvons gagner une bonne vitesse par rapport aux meilleures méthodes publiées jusqu'à présent. La stratégie de base est similaire : faire une grande super chaîne. Mais le traitement semble beaucoup plus rapide avec numpy, sans doute parce que nous exploitons pleinement la simplicité de l'opération de remplacement de rien par quelque chose.

Pour les plus petits (moins de 0x110000 caractères au total), nous trouvons automatiquement un séparateur, pour les problèmes plus importants, nous utilisons une méthode plus lente qui ne repose pas sur des str.split .

Notez que j'ai déplacé tous les précomptables hors des fonctions. Notez aussi que translate et pd_translate découvrez gratuitement le seul séparateur possible pour les trois plus grands problèmes alors que np_multi_strat doit le calculer ou se rabattre sur la stratégie sans séparateur. Enfin, notez que pour les trois derniers points de données, je passe à un problème plus "intéressant" ; pd_replace et re_sub parce qu'elles ne sont pas équivalentes aux autres méthodes ont dû être exclues pour cela.

enter image description here

Sur l'algorithme :

La stratégie de base est en fait assez simple. Il n'y a que 0x110000 différents caractères unicode. Comme le PO présente le défi en termes d'énormes ensembles de données, il est parfaitement utile de créer une table de consultation qui a True aux identifiants des caractères que nous voulons conserver et False à ceux qui doivent aller --- la ponctuation dans notre exemple.

Une telle table de consultation peut être utilisée pour la consultation en masse en utilisant l'indexation avancée de numpy. Comme la consultation est entièrement vectorisée et revient essentiellement à déréférencer un tableau de pointeurs, elle est beaucoup plus rapide que la consultation d'un dictionnaire, par exemple. Ici, nous utilisons le numpy view casting qui permet de réinterpréter les caractères unicode comme des entiers, essentiellement gratuitement.

L'utilisation du tableau de données qui ne contient qu'une seule chaîne de caractères monstre réinterprétée comme une séquence de nombres pour indexer la table de consultation donne un masque booléen. Ce masque peut ensuite être utilisé pour filtrer les caractères indésirables. En utilisant l'indexation booléenne, ceci, aussi, est une seule ligne de code.

Jusqu'ici, tout est simple. La partie la plus délicate est de découper la chaîne de caractères du monstre en ses parties. Si nous avons un séparateur, c'est-à-dire un caractère qui n'apparaît pas dans les données ou dans la liste de ponctuation, alors c'est encore facile. Utilisez ce caractère pour joindre et reséparer. Cependant, trouver automatiquement un séparateur est un défi et représente en fait la moitié de la localisation dans l'implémentation ci-dessous.

Une autre solution consiste à conserver les points de séparation dans une structure de données distincte, à suivre leur déplacement à la suite de la suppression de caractères indésirables, puis à les utiliser pour découper la chaîne de monstres traitée. Comme le découpage en parties de longueur inégale n'est pas le point fort de numpy, cette méthode est plus lente que la méthode str.split et n'est utilisé que comme solution de repli lorsqu'un séparateur serait trop coûteux à calculer s'il existait en premier lieu.

Code (timing/traçage fortement basé sur le post de @COLDSPEED) :

import numpy as np
import pandas as pd
import string
import re

spct = np.array([string.punctuation]).view(np.int32)
lookup = np.zeros((0x110000,), dtype=bool)
lookup[spct] = True
invlookup = ~lookup
OSEP = spct[0]
SEP = chr(OSEP)
while SEP in string.punctuation:
    OSEP = np.random.randint(0, 0x110000)
    SEP = chr(OSEP)

def find_sep_2(letters):
    letters = np.array([letters]).view(np.int32)
    msk = invlookup.copy()
    msk[letters] = False
    sep = msk.argmax()
    if not msk[sep]:
        return None
    return sep

def find_sep(letters, sep=0x88000):
    letters = np.array([letters]).view(np.int32)
    cmp = np.sign(sep-letters)
    cmpf = np.sign(sep-spct)
    if cmp.sum() + cmpf.sum() >= 1:
        left, right, gs = sep+1, 0x110000, -1
    else:
        left, right, gs = 0, sep, 1
    idx, = np.where(cmp == gs)
    idxf, = np.where(cmpf == gs)
    sep = (left + right) // 2
    while True:
        cmp = np.sign(sep-letters[idx])
        cmpf = np.sign(sep-spct[idxf])
        if cmp.all() and cmpf.all():
            return sep
        if cmp.sum() + cmpf.sum() >= (left & 1 == right & 1):
            left, sep, gs = sep+1, (right + sep) // 2, -1
        else:
            right, sep, gs = sep, (left + sep) // 2, 1
        idx = idx[cmp == gs]
        idxf = idxf[cmpf == gs]

def np_multi_strat(df):
    L = df['text'].tolist()
    all_ = ''.join(L)
    sep = 0x088000
    if chr(sep) in all_: # very unlikely ...
        if len(all_) >= 0x110000: # fall back to separator-less method
                                  # (finding separator too expensive)
            LL = np.array((0, *map(len, L)))
            LLL = LL.cumsum()
            all_ = np.array([all_]).view(np.int32)
            pnct = invlookup[all_]
            NL = np.add.reduceat(pnct, LLL[:-1])
            NLL = np.concatenate([[0], NL.cumsum()]).tolist()
            all_ = all_[pnct]
            all_ = all_.view(f'U{all_.size}').item(0)
            return df.assign(text=[all_[NLL[i]:NLL[i+1]]
                                   for i in range(len(NLL)-1)])
        elif len(all_) >= 0x22000: # use mask
            sep = find_sep_2(all_)
        else: # use bisection
            sep = find_sep(all_)
    all_ = np.array([chr(sep).join(L)]).view(np.int32)
    pnct = invlookup[all_]
    all_ = all_[pnct]
    all_ = all_.view(f'U{all_.size}').item(0)
    return df.assign(text=all_.split(chr(sep)))

def pd_replace(df):
    return df.assign(text=df['text'].str.replace(r'[^\w\s]+', ''))

p = re.compile(r'[^\w\s]+')

def re_sub(df):
    return df.assign(text=[p.sub('', x) for x in df['text'].tolist()])

punct = string.punctuation.replace(SEP, '')
transtab = str.maketrans(dict.fromkeys(punct, ''))

def translate(df):
    return df.assign(
        text=SEP.join(df['text'].tolist()).translate(transtab).split(SEP)
    )

# MaxU's version (https://stackoverflow.com/a/50444659/4909087)
def pd_translate(df):
    return df.assign(text=df['text'].str.translate(transtab))

from timeit import timeit

import pandas as pd
import matplotlib.pyplot as plt

res = pd.DataFrame(
       index=['translate', 'pd_replace', 're_sub', 'pd_translate', 'np_multi_strat'],
       columns=[10, 50, 100, 500, 1000, 5000, 10000, 50000, 100000, 500000,
                1000000],
       dtype=float
)

for c in res.columns:
    if c >= 100000: # stress test the separator finder
        all_ = np.r_[:OSEP, OSEP+1:0x110000].repeat(c//10000)
        np.random.shuffle(all_)
        split = np.arange(c-1) + \
                np.sort(np.random.randint(0, len(all_) - c + 2, (c-1,))) 
        l = [x.view(f'U{x.size}').item(0) for x in np.split(all_, split)]
    else:
        l = ['a..b?!??', '%hgh&12','abc123!!!', '$$$1234'] * c
    df = pd.DataFrame({'text' : l})
    for f in res.index: 
        if f == res.index[0]:
            ref = globals()[f](df).text
        elif not (ref == globals()[f](df).text).all():
            res.at[f, c] = np.nan
            print(f, 'disagrees at', c)
            continue
        stmt = '{}(df)'.format(f)
        setp = 'from __main__ import df, {}'.format(f)
        res.at[f, c] = timeit(stmt, setp, number=16)

ax = res.div(res.min()).T.plot(loglog=True) 
ax.set_xlabel("N"); 
ax.set_ylabel("time (relative)");

plt.show()

20voto

MaxU Points 5284

Il est intéressant de noter que la vectorisation Series.str.translate est toujours légèrement plus lente que celle de Vanilla Python. str.translate() :

def pd_translate(df):
    return df.assign(text=df['text'].str.translate(transtab))

enter image description here

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