web-dev-qa-db-fra.com

Suppression rapide de la ponctuation avec pandas

Ceci est un message auto-répondu. Ci-dessous, je décris un problème commun dans le domaine de la PNL et propose quelques méthodes performantes pour le résoudre.

Souvent, il est nécessaire de supprimer ponctuation lors du nettoyage et du pré-traitement du texte. La ponctuation est définie comme tout caractère dans string.punctuation:

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

Ceci est un problème assez commun et a été demandé avant ad nauseam. La solution la plus idiomatique utilise pandas str.replace. Cependant, pour les situations impliquant un lot de texte , une solution plus performante devra peut-être être envisagée.

Quelles sont les bonnes alternatives performantes à str.replace Lorsque vous traitez avec des centaines de milliers d'enregistrements?

48
cs95

Installer

Aux fins de 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, je liste les alternatives, une par une, par ordre croissant de performance

str.replace

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

Ceci utilise la fonction pandas intégrée 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

Ceci est très facile à coder, et est assez lisible, mais lent.


regex.sub

Cela implique l'utilisation de la fonction sub de la bibliothèque re. Pré-compilez un motif regex pour la performance et appelez regex.sub Dans une liste de compréhension. Si vous parvenez à économiser de la mémoire, convertissez au préalable df['text'] En une liste.

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

Remarque: Si vos données contiennent des valeurs de NaN, cela (ainsi que la méthode suivante ci-dessous) ne fonctionnera pas tel quel. Voir la section "Autres considérations ".


str.translate

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

Comment ça marche, c'est:

  1. Tout d’abord, réunissez toutes vos chaînes pour former une chaîne énorme en utilisant un seul (ou plusieurs) caractère séparateur qui = vous choisissez. Vous must utilisez un caractère/une chaîne que vous pouvez garantir ne fera pas partie de vos données.
  2. Effectuez str.translate Sur la grande chaîne en supprimant la ponctuation (le séparateur de l'étape 1 est exclu).
  3. Fractionnez la chaîne sur le séparateur utilisé pour la jonction à l'étape 1. La liste résultante must a 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 canal, vous devez 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 de loin le meilleur. Notez que le graphique ci-dessous inclut une autre variante Series.str.translate De réponse de Max .

(Fait intéressant, je le répète une deuxième fois et les résultats sont légèrement différents de ceux d’avant. Lors de la deuxième exécution, il semble que re.sub Ait gagné plus de str.translate Pour de très petites quantités de données.) enter image description here

Il y a un risque inhérent à l'utilisation de translate (en particulier, le problème de automatisation du processus consistant à choisir le séparateur à utiliser n'est pas trivial), mais les compromis sont les suivants: vaut le risque.


Autres considérations

Gestion des NaN avec des méthodes de compréhension de liste; Notez que cette méthode (et la suivante) ne fonctionneront que tant que vos données ne possèdent pas de NaN. Lors de la manipulation de NaN, vous devrez déterminer les index des valeurs non NULL et les remplacer uniquement. Essayez quelque chose comme ça:

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

Traitement des DataFrames; Si vous utilisez des DataFrames, où every la colonne 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 la fonction translate est définie ci-dessous dans le code de benchmarking.

Chaque solution comporte des compromis. La décision de choisir celle qui correspond le mieux à vos besoins dépend 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, utilisez-la avec prudence.

Une autre considération est la complexité de votre regex. Parfois, vous voudrez peut-être supprimer tout ce qui n'est pas alphanumérique ou d'espace. D'autres fois, vous devrez conserver certains caractères, tels que des traits d'union, des deux-points et des terminateurs de phrase [.!?]. Leur spécification ajoute explicitement à la complexité de votre expression rationnelle, ce qui peut également influer sur les performances de ces solutions. Assurez-vous de tester ces solutions sur vos données avant de décider quoi utiliser.

Enfin, les caractères unicode seront supprimés avec cette solution. Vous voudrez peut-être modifier votre expression rationnelle (si vous utilisez une solution basée sur une expression régulière), ou simplement utiliser str.translate Sinon.

Pour une performance égale plus (pour un plus grand N), jetez un oeil à cette réponse par Paul Panzer .


Appendice

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'analyse 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()
50
cs95

En utilisant numpy, nous pouvons obtenir une accélération saine par rapport aux meilleures méthodes publiées jusqu'à présent. La stratégie de base est similaire: créez une grande chaîne super. Mais le traitement semble beaucoup plus rapide, probablement parce que nous exploitons pleinement la simplicité de l'opération de remplacement rien pour rien.

Pour les plus petites (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 str.split.

Notez que j'ai déplacé tous les précalculables des fonctions. Notez également que translate et pd_translate apprenez à connaître le seul séparateur possible pour les trois plus gros problèmes alors que np_multi_strat doit le calculer ou avoir recours à 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 car ils ne sont pas équivalents aux autres méthodes ont dû être exclus pour cela.

enter image description here

Sur l'algorithme:

La stratégie de base est en réalité assez simple. Il y a seulement 0x110000 différents caractères unicode. Comme OP définit le défi en termes d’énormes jeux de données, il est tout à fait utile de créer une table de consultation qui contient True au niveau de l’identifiant de caractère que nous voulons conserver et False à ceux qui doivent le faire. go --- la ponctuation dans notre exemple.

Une telle table de correspondance peut être utilisée pour la recherche en bloc en utilisant l'indexation avancée de numpy. Comme la recherche est entièrement vectorisée et revient essentiellement à déréférencer un tableau de pointeurs, elle est beaucoup plus rapide que, par exemple, la recherche par dictionnaire. Nous utilisons ici la conversion de vues numpy qui permet de réinterpréter les caractères unicode sous forme d’entiers essentiellement gratuits.

L'utilisation du tableau de données qui contient une seule chaîne de monstres réinterprétée en tant que séquence de nombres à indexer dans la table de recherche donne un masque booléen. Ce masque peut ensuite être utilisé pour filtrer les caractères indésirables. À l’aide de l’indexation booléenne, il s’agit également d’une seule ligne de code.

Jusqu'ici si simple. La difficulté réside dans le découpage de la chaîne de 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 ni dans la liste de ponctuation, cela reste simple. Utilisez ce personnage pour rejoindre et redimensionner. Cependant, trouver automatiquement un séparateur est difficile et représente en effet la moitié de la localisation dans la mise en œuvre ci-dessous.

Alternativement, nous pouvons conserver les points de partage dans une structure de données distincte, suivre leur déplacement suite à la suppression de caractères indésirables, puis les utiliser pour découper la chaîne de monstre 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 str.split et utilisé seulement comme solution de secours lorsqu'un séparateur serait trop coûteux à calculer s'il existait au départ.

Code (timing/graphique 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()
29
Paul Panzer

Suffisamment intéressant pour que la méthode vectorisée Series.str.translate soit encore un peu plus lente que Vanilla Python str.translate():

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

enter image description here

18
MaxU