Eliminación rápida de puntuación con pandas

Resuelto cs95 asked hace 6 años • 4 respuestas

Esta es una publicación con respuesta propia. A continuación describo un problema común en el dominio de la PNL y propongo algunos métodos eficaces para resolverlo.

A menudo surge la necesidad de eliminar la puntuación durante la limpieza y el preprocesamiento del texto. La puntuación se define como cualquier carácter en string.punctuation:

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

Este es un problema bastante común y se ha preguntado hasta la saciedad. La solución más idiomática utiliza pandas str.replace. Sin embargo, para situaciones que implican mucho texto , es posible que sea necesario considerar una solución más eficaz.

¿Cuáles son algunas alternativas buenas y de alto rendimiento str.replacecuando se trata de cientos de miles de registros?

cs95 avatar May 21 '18 14:05 cs95
Aceptado

Configuración

Con fines de demostración, consideremos este DataFrame.

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

A continuación, enumero las alternativas, una por una, en orden creciente de desempeño.

str.replace

Esta opción se incluye para establecer el método predeterminado como punto de referencia para comparar otras soluciones de mayor rendimiento.

Esto utiliza la función incorporada de Pandas str.replaceque realiza un reemplazo basado en expresiones regulares.

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

df
     text
0      ab
1   hgh12
2  abc123
3    1234

Es muy fácil de codificar y bastante legible, pero lento.


regex.sub

Esto implica utilizar la subfunción de la rebiblioteca. Precompila un patrón de expresiones regulares para el rendimiento y llama regex.subdentro de una lista de comprensión. Convierta df['text']a una lista de antemano si tiene algo de memoria, obtendrá un pequeño aumento de rendimiento con esto.

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

Nota: Si sus datos tienen valores NaN, esto (así como el siguiente método a continuación) no funcionará tal como está. Consulte la sección sobre " Otras consideraciones ".


str.translate

La función de Python str.translateestá implementada en C y, por lo tanto, es muy rápida. .

Cómo funciona esto es:

  1. Primero, une todas tus cadenas para formar una cadena enorme usando un solo (o más) separador de caracteres que elijas . Debe utilizar un carácter/subcadena que pueda garantizar que no pertenecerá a sus datos.
  2. Llevar a cabostr.translate en la cadena grande, eliminando la puntuación (se excluye el separador del paso 1).
  3. Divida la cadena en el separador que se usó para unir en el paso 1. La lista resultante debe tener la misma longitud que la columna inicial.

Aquí, en este ejemplo, consideramos el separador de tuberías |. Si sus datos contienen la tubería, entonces debe elegir otro separador.

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

Actuación

str.translatefunciona mejor, con diferencia. Tenga en cuenta que el siguiente gráfico incluye otra variante Series.str.translatede la respuesta de MaxU. .

(Curiosamente, volví a ejecutar esto por segunda vez y los resultados son ligeramente diferentes a los anteriores. Durante la segunda ejecución, parece que re.subestaba ganando str.translatecon cantidades de datos realmente pequeñas). ingrese la descripción de la imagen aquí

Existe un riesgo inherente al uso translate(en particular, el problema de automatizar el proceso de decidir qué separador usar no es trivial), pero las compensaciones valen el riesgo.


Otras Consideraciones

Manejo de NaN con métodos de comprensión de listas; Tenga en cuenta que este método (y el siguiente) solo funcionará mientras sus datos no tengan NaN. Al manejar NaN, deberá determinar los índices de valores no nulos y reemplazarlos únicamente. Pruebe algo como esto:

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

Manejo de marcos de datos; Si se trata de DataFrames, donde cada columna requiere reemplazo, el procedimiento es simple:

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

O,

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

Tenga en cuenta que eltranslate función se define a continuación con el código de evaluación comparativa.

Cada solución tiene sus ventajas y desventajas, por lo que decidir qué solución se adapta mejor a sus necesidades dependerá de lo que esté dispuesto a sacrificar. Dos consideraciones muy comunes son el rendimiento (que ya hemos visto) y el uso de memoria.str.translatees una solución que consume mucha memoria, así que utilícela con precaución.

Otra consideración es la complejidad de su expresión regular. A veces, es posible que desees eliminar todo lo que no sea alfanumérico o espacios en blanco. En otras ocasiones, deberá conservar ciertos caracteres, como guiones, dos puntos y terminadores de oraciones.[.!?] . Especificarlos explícitamente agrega complejidad a su expresión regular, lo que a su vez puede afectar el rendimiento de estas soluciones. Asegúrese de probar estas soluciones en sus datos antes de decidir qué utilizar.

Por último, los caracteres Unicode se eliminarán con esta solución. Es posible que desee modificar su expresión regular (si utiliza una solución basada en expresiones regulares) o simplemente utilizarstr.translate lo contrario.

Para obtener un rendimiento aún mayor (para N más grande), eche un vistazo a esta respuesta de Paul Panzer .


Apéndice

Funciones

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))

Código de evaluación comparativa de rendimiento

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()
cs95 avatar May 21 '2018 07:05 cs95

Usando numpy podemos obtener una aceleración saludable con respecto a los mejores métodos publicados hasta ahora. La estrategia básica es similar: hacer una súper cuerda grande. Pero el procesamiento parece mucho más rápido en numpy, presumiblemente porque explotamos al máximo la simplicidad de la operación de reemplazo de nada por algo.

Para problemas más pequeños (menos que 0x110000caracteres en total), encontramos automáticamente un separador, para problemas más grandes usamos un método más lento que no depende de str.split.

Tenga en cuenta que he sacado todos los precalculables de las funciones. También tenga en cuenta eso translatey pd_translateconozca el único separador posible para los tres problemas más grandes de forma gratuita, mientras que np_multi_strattiene que calcularlo o recurrir a la estrategia sin separadores. Y finalmente, tenga en cuenta que para los últimos tres puntos de datos cambio a un problema más "interesante"; pd_replacey re_subdebido a que no son equivalentes a los otros métodos tuvieron que ser excluidos por eso.

ingrese la descripción de la imagen aquí

Sobre el algoritmo:

La estrategia básica es bastante simple. Sólo hay 0x110000diferentes caracteres Unicode. Dado que OP enmarca el desafío en términos de enormes conjuntos de datos, vale la pena crear una tabla de búsqueda que tenga Truelas identificaciones de caracteres que queremos conservar y Falselas que deben desaparecer: la puntuación en nuestro ejemplo.

Una tabla de búsqueda de este tipo se puede utilizar para búsquedas masivas utilizando la indexación avanzada de numpy. Como la búsqueda está completamente vectorizada y esencialmente equivale a eliminar la referencia a una serie de punteros, es mucho más rápida que, por ejemplo, la búsqueda en un diccionario. Aquí utilizamos la conversión de vistas numpy que permite reinterpretar caracteres Unicode como números enteros esencialmente de forma gratuita.

El uso de la matriz de datos que contiene solo una cadena monstruosa reinterpretada como una secuencia de números para indexar en la tabla de búsqueda da como resultado una máscara booleana. Luego, esta máscara se puede utilizar para filtrar los caracteres no deseados. El uso de indexación booleana también es una sola línea de código.

Hasta aquí todo muy sencillo. Lo complicado es cortar la cuerda del monstruo en sus partes. Si tenemos un separador, es decir, un carácter que no aparece en los datos o en la lista de puntuación, entonces aún así es fácil. Usa este personaje para unirte y volver a dividirte. Sin embargo, encontrar automáticamente un separador es un desafío y, de hecho, representa la mitad de la ubicación en la implementación siguiente.

Alternativamente, podemos mantener los puntos de división en una estructura de datos separada, rastrear cómo se mueven como consecuencia de la eliminación de caracteres no deseados y luego usarlos para dividir la cadena monstruosa procesada. Como cortar en partes de longitud desigual no es el punto fuerte de numpy, este método es más lento str.splity solo se usa como alternativa cuando un separador sería demasiado costoso de calcular si existiera en primer lugar.

Código (timing/trazado basado en gran medida en la publicación 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()
Paul Panzer avatar May 24 '2018 22:05 Paul Panzer