español - pandas python
Eliminación rápida de la puntuación con pandas (3)
Esta es una publicación auto contestada. A continuación, describo un problema común en el dominio de la PNL y propongo algunos métodos 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 antes de nauseam.
La solución más idiomática utiliza pandas
str.replace
.
Sin embargo, para situaciones que involucran una
gran cantidad
de texto, es posible que se deba considerar una solución más eficaz.
¿Cuáles son algunas alternativas
str.replace
y
str.replace
para
str.replace
al tratar con cientos de miles de registros?
Preparar
Para 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 rendimiento.
str.replace
Esta opción se incluye para establecer el método predeterminado como un punto de referencia para comparar otras soluciones de mayor rendimiento.
Esto utiliza la función
str.replace
incorporada de
str.replace
que realiza el reemplazo basado en
str.replace
regulares.
df[''text''] = df[''text''].str.replace(r''[^/w/s]+'', '''')
df
text
0 ab
1 hgh12
2 abc123
3 1234
Esto es muy fácil de codificar, y es bastante legible, pero lento.
regex.sub
Esto implica usar la
sub
de la biblioteca
re
.
Pre-compile un patrón de
regex.sub
regulares para el rendimiento, y llame a
regex.sub
dentro de una lista de comprensión.
Convierta de antemano el
df[''text'']
a una lista. Si puede ahorrar algo de memoria, obtendrá un pequeño aumento de rendimiento.
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 de NaN, esto (así como el siguiente método a continuación) no funcionarán como están. Consulte la sección sobre " Otras consideraciones ".
str.translate
La función
str.translate
de python se implementa 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 utilizando un separador de caracteres único (o más) que elijas. Debe usar un carácter / subcadena que pueda garantizar que no pertenezca a sus datos.
-
Ejecute
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 unirse en el paso 1. La lista resultante debe tener la misma longitud que su columna inicial.
Aquí, en este ejemplo, consideramos el separador de tubería
|
.
Si sus datos contienen la tubería, 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
realiza el mejor, con diferencia.
Tenga en cuenta que la gráfica a continuación incluye otra variante
Series.str.translate
de
la respuesta
de
MaxU
.
(Curiosamente, lo repito por segunda vez, y los resultados son ligeramente diferentes a los de antes. Durante la segunda ejecución, parece que
re.sub
estaba ganando en
str.translate
para cantidades de datos realmente pequeñas).
Existe un riesgo inherente relacionado con el uso de la
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 NaNs con métodos de comprensión de lista; Tenga en cuenta que este método (y el siguiente) solo funcionará mientras sus datos no tengan NaN. Cuando maneje los NaN, deberá determinar los índices de valores no nulos y reemplazarlos solo. Intenta 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
Tratar con DataFrames; Si está tratando con 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 la función de
translate
se define a continuación con el código de evaluación comparativa.
Cada solución tiene 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 la memoria.
str.translate
es una solución que
str.translate
memoria, por lo tanto, utilice con precaución.
Otra consideración es la complejidad de su expresión regular.
A veces, es posible que desee eliminar todo lo que no sea alfanumérico o espacio en blanco.
En otras ocasiones, deberá conservar ciertos caracteres, como guiones, dos puntos y terminadores de oraciones
[.!?]
.
Especificar esto agrega explícitamente 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é usar.
Por último, los caracteres Unicode se eliminarán con esta solución.
Es posible que desee ajustar su expresión regular (si utiliza una solución basada en
str.translate
), o simplemente ir con
str.translate
contrario.
Para obtener aún más rendimiento (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://.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 rendimiento de referencia
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()
Con el uso de NUMPY podemos obtener una aceleración saludable con los mejores métodos publicados hasta ahora. La estrategia básica es similar: hacer una cuerda super grande. Pero el procesamiento parece mucho más rápido en números, probablemente porque explotamos completamente la simplicidad de la operación de reemplazo de nada por algo.
Para problemas más pequeños (menos de
0x110000
total) encontramos automáticamente un separador, para problemas más grandes utilizamos un método más lento que no se basa en
str.split
.
Tenga en cuenta que he movido todos los precomputables de las funciones.
También tenga en cuenta que
translate
y
pd_translate
conocen 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 separador.
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
porque no son equivalentes a los otros métodos que se tuvieron que excluir para eso.
En el algoritmo:
La estrategia básica es bastante simple.
Sólo hay
0x110000
caracteres Unicode diferentes.
Como OP encuadra el desafío en términos de grandes conjuntos de datos, vale la pena hacer una tabla de búsqueda que tenga
True
en los identificadores de caracteres que queremos mantener y
False
en los que deben ir: la puntuación en nuestro ejemplo.
Dicha tabla de búsqueda puede usarse para la búsqueda masiva 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 el diccionario. Aquí utilizamos la conversión de vistas numpy que permite reinterpretar los caracteres Unicode como enteros esencialmente de forma gratuita.
El uso de la matriz de datos que contiene solo una cadena de monstruos reinterpretada como una secuencia de números para indexar en la tabla de búsqueda da como resultado una máscara booleana. Esta máscara se puede usar para filtrar los caracteres no deseados. Usando la indexación booleana, esto también es una sola línea de código.
Hasta aquí tan simple. Lo difícil es cortar la cadena del monstruo de nuevo 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 es fácil. Usa este personaje para unirte y volver a dividir. Sin embargo, encontrar un separador automáticamente es un desafío y, de hecho, representa la mitad del lugar en la implementación a continuación.
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 cortar la cadena de monstruo procesada.
Dado que cortar piezas en una longitud desigual no es el palo más fuerte de Numpy, este método es más lento que el
str.split
y solo se usa como respaldo cuando un separador sería demasiado caro para calcular si existió en primer lugar.
Código (tiempo / trazado basado 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://.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()
Es bastante interesante que el método vectorizado
Series.str.translate
es todavía un poco más lento en comparación con Vanilla Python
str.translate()
:
def pd_translate(df):
return df.assign(text=df[''text''].str.translate(transtab))