Eliminación rápida de puntuación con pandas
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.replace
cuando se trata de cientos de miles de registros?
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.replace
que 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 sub
función de la re
biblioteca. Precompila un patrón de expresiones regulares para el rendimiento y llama regex.sub
dentro 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.translate
está implementada en C y, por lo tanto, es muy rápida. .
Cómo funciona esto es:
- 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.
- Llevar a cabo
str.translate
en la cadena grande, eliminando la puntuación (se excluye el separador del paso 1). - 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.translate
funciona mejor, con diferencia. Tenga en cuenta que el siguiente gráfico incluye otra variante Series.str.translate
de 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.sub
estaba ganando str.translate
con cantidades de datos realmente pequeñas).
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.translate
es 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()
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 0x110000
caracteres 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 translate
y pd_translate
conozca el único separador posible para los tres problemas más grandes de forma gratuita, mientras que np_multi_strat
tiene 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_replace
y re_sub
debido a que no son equivalentes a los otros métodos tuvieron que ser excluidos por eso.
Sobre el algoritmo:
La estrategia básica es bastante simple. Sólo hay 0x110000
diferentes 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 True
las identificaciones de caracteres que queremos conservar y False
las 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.split
y 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()