Esta guía muestra cómo elegir una estrategia para comparar registros
sin ocultar el costo ni la pérdida de alcance. La comparación aproximada
está apagada en perfilar(); se solicita de forma explícita
y siempre informa qué pares se miraron.
Las llamadas a stringdist usan por omisión
nucleos = getOption("lupa.nucleos", 2L). El valor efectivo
queda en alcance$nucleos_usados; se puede cambiar por
llamada o mediante la opción, pero la cantidad de hilos no cambia los
pares ni los hallazgos, sólo el tiempo. Las mediciones de escala fijan
dos hilos para que los tiempos sean comparables entre corridas.
Los tiempos de las dos tablas siguientes se midieron en la máquina
que describe benchmark/datos/entorno.csv (un Intel Core
i9-14900HX, Linux, R 4.6.1). Son una referencia de escala, no una
predicción para otra máquina.
Sobre el padrón sintético de
benchmark/_padron_sintetico.R —100.000 filas, vocabulario
chico con homónimos y erratas sembradas, la forma que más llena las
cubetas del LSH— con 140.097.499 candidatos y 205.865 pares informados,
la cantidad de hilos produjo estas medianas (tres procesos separados por
configuración; benchmark/medir_figuras.R las deja en
benchmark/datos/hilos.csv y es lo que dibuja el cuarto
panel de la figura de escala del README):
| hilos | mediana (s) | relativo a 2 |
|---|---|---|
| 2 | 153,20 | 1,00x |
| 4 | 118,38 | 0,77x |
| 8 | 99,29 | 0,65x |
| 16 | 85,01 | 0,55x |
| 29 | 83,27 | 0,54x |
Sobre otro padrón sintético —100.000 filas, 23.800 nombres distintos,
la misma máquina; lo genera benchmark/medir_escala_hilos.R—
la curva es mucho más plana:
| hilos | mediana (s) | relativo a 2 |
|---|---|---|
| 2 | 324,36 | 1,00x |
| 4 | 303,84 | 0,94x |
| 8 | 296,93 | 0,92x |
| 16 | 290,42 | 0,90x |
| 31 | 302,18 | 0,93x |
La ganancia no es una propiedad del paquete: depende de qué
fracción del trabajo cae en la parte que se paraleliza. Los
hilos los usa stringdist al comparar; generar los
candidatos y armar los grupos no los usa. En el primer padrón la
comparación domina el reloj y dieciséis hilos lo bajan a 0,55× de lo que
tarda con dos; en el segundo domina el resto y la ganancia es de un 10 %
(290,42 s frente a 324,36 s). Quien vaya a subir el valor conviene que
mida su propio caso, no que copie una de estas dos tablas.
Lo que sí vale en las dos: pasados dieciséis hilos
la ganancia queda dentro del ruido entre corridas (un 2 % en la primera
tabla, con corridas de 16 y de 29 hilos que se solapan; ninguna en la
segunda) —el valor predeterminado de stringdist, todos los
núcleos menos uno, no es el mejor— y el resultado no cambia, sólo el
reloj. En la primera tabla, las cinco configuraciones dan los mismos
140.097.499 candidatos y los mismos 205.865 pares, y el medidor se
detiene si las tres corridas de una configuración no coinciden; en la
segunda, la comprobación compara los 50.000 pares informados uno por uno
entre las cinco configuraciones, y no su cantidad: como el tope de
resultados se alcanza en todas, contar pares habría dado siempre el
mismo número y la comprobación se habría cumplido sola. El valor por
omisión de dos hilos es deliberadamente conservador.
Un conjunto pequeño
library(lupa)
datos <- data.frame(
id = 1:8,
nombre = c(
"Ana Perez", "Ana Peres", "Luis Silva", "Luis Silva",
"Marta Gómez", "Marta Gomez", "Rosa Díaz", "Rosa Diaz"
),
domicilio = c(
"Calle 1 123", "Calle 1 123", "Ruta 5 40", "Ruta 5 40",
"Plaza 2 8", "Plaza 2 8", "Calle 9 20", "Calle 9 20"
),
anio = c(2022, 2022, 2021, 2021, 2020, 2020, NA, NA),
stringsAsFactors = FALSE
)Estimar antes de comparar
estimar_costo() es un acto deliberado: devuelve la
cantidad prevista de candidatos, la muestra usada y, cuando corresponde,
un pronóstico de tiempo marcado como no determinista. En el camino
exacto la cuenta de pares es exacta.
estimacion <- estimar_costo(
datos, columnas = c("nombre", "domicilio"), estrategia = "lsh",
lsh_muestra_estimacion = 100
)
estimacion[c(
"candidatos_previstos", "probabilidad_candidato_estimada",
"muestra_estimacion", "vocabulario"
)]
#> $candidatos_previstos
#> [1] 4
#>
#> $probabilidad_candidato_estimada
#> [1] 0.1428571
#>
#> $muestra_estimacion
#> [1] 28
#>
#> $vocabulario
#> [1] 72El pronóstico de tiempo, cuando aparece, es un piso de la etapa de
comparación stringdist: no incluye la firma MinHash, el
bandeo, las cubetas ni el troceo. Su campo
estimacion$tiempo_estimado_etapa lo deja explícito.
Con dos hilos, ese piso cubrió aproximadamente el 80 % del tiempo
total en el control de 100.000 filas (106,18 s estimados frente a 133,28
s medidos). Sigue siendo un piso de la comparación
stringdist, no una promesa de duración de la corrida
completa.
Si el pronóstico supera el límite elegido,
presupuesto_pares corta antes de comparar. En una sesión
interactiva se puede decidir explícitamente continuar; en un script no
se solicita entrada.
Un tope de largo para la señal de proximidad
La distancia normalizada sirve para nombres, direcciones e identificadores, pero pierde capacidad de distinguir documentos largos. En cinco semillas de textos aleatorios, un par con una diferencia y otro con 1.000 diferencias dio estas medianas sobre cinco semillas:
| largo | una diferencia | 1.000 diferencias |
|---|---|---|
| 10.000 | 0,000400 | 0,193195 |
| 20.000 | 0,000150 | 0,125923 |
| 25.000 | 0,000347 | 0,104511 |
| 50.000 | 0,000053 | 0,064559 |
Con el umbral predeterminado 0,10, el segundo par cruza
hacia un falso parecido entre 25.000 y 50.000 caracteres. Por eso el
tope predeterminado es max_largo_valor = 10000: deja margen
antes de ese cruce y no aplica una distancia sin señal a textos largos.
Una columna que lo supera queda fuera de la combinacion completa y se
declara en alcance$columnas_excluidas_largo. En
perfilar(), la cobertura explica la columna, el largo
observado y el umbral. Inf es la eleccion explicita para
recuperar el comportamiento anterior; en el diagnostico de vocabulario
se escribe como max_largo_valor_vocabulario = Inf.
Qué largo es el que cuenta
El tope no se mide sobre el valor tal como está guardado, sino sobre la cadena que entra a la comparación: las columnas ya combinadas y ya normalizadas. La diferencia no es teórica y se ve en dos casos que parecen inocentes.
Dos columnas de nueve mil caracteres cada una están las dos por
debajo del tope, y la comparación no las mira por separado: las une con
un separador y compara los dieciocho mil de la fila entera. Y la
normalización amplio expande ligaduras —la
tipográfica de f-f-l es un solo carácter que se convierte en tres—, de
modo que un valor puede quedar por debajo del tope guardado y por encima
del comparado.
Por eso alcance$largo_maximo publica el largo
comparado y no el guardado. Cuando el tope no aplica
—Inf— no se mide ningún largo, y ese campo vale
NA: un cero ahí sería afirmar una medición que no
ocurrió.
Dos topes, dos perillas
max_largo_valor_vocabulario gobierna la regla de
vocabulario dentro de perfilar(). El detector de pares de
filas tiene el suyo y no se hereda: se configura por
separado, con
duplicados_aproximados = list(max_largo_valor = ...). Son
dos análisis distintos sobre los mismos datos, y ponerle a uno el tope
del otro sería decidir por quien perfila.
Avisar el costo de tablas anchas
perfilar() calcula una proyeccion antes de empezar las
etapas costosas. Desde 100.000 celdas avisa en una sesion
interactiva, con una referencia de 10.000 celdas por segundo y un texto
que dice que es una estimacion y cual es su fuente. El aviso queda en
silencio en scripts no interactivos y se puede desactivar con
avisar_costo_tabla_ancha = FALSE; usar
umbral_celdas_aviso_tabla_ancha = Inf tambien es una
decision explicita. La proyeccion completa queda en
meta$costo_tabla_ancha.
Comparación exacta y LSH
Hasta el tope de pares, la estrategia por teselas es exhaustiva y
conserva la misma respuesta que una corrida sin lotes. Para tablas
grandes, LSH genera candidatos mediante MinHash y declara en
alcance su muestra, sus bandas y la garantía de
colisión.
exacto <- detectar_duplicados_aproximados(
datos, columnas = c("nombre", "domicilio"), estrategia = "teselas",
max_resultados = 100
)
lsh <- detectar_duplicados_aproximados(
datos, columnas = c("nombre", "domicilio"), estrategia = "lsh",
max_resultados = 100
)
#> LSH: 4 candidatos previstos; referencia de 0,000 s (piso, no incluye firma ni cubetas;
#> subir nucleos puede acortar esta etapa; hoy usa 2 hilos), medida con 25.256 pares en
#> 0,051 s.
exacto$pares[, c(
"fila_1", "fila_2", "distancia", "tipo_par", "igualo_normalizar"
)]
#> fila_1 fila_2 distancia tipo_par igualo_normalizar
#> 1 3 4 0.0000000 exacto FALSE
#> 2 5 6 0.0000000 exacto_normalizado TRUE
#> 3 7 8 0.0000000 exacto_normalizado TRUE
#> 4 1 2 0.0173913 aproximado FALSE
lsh$alcance[, c("modo_comparacion", "n_pares_comparados", "n_pares_hallados")]
#> modo_comparacion n_pares_comparados n_pares_hallados
#> 1 lsh_minhash 4 4tipo_par = "exacto" se reserva para textos guardados
iguales. "exacto_normalizado" identifica una coincidencia
producida por la normalización y igualo_normalizar la marca
fila a fila; "aproximado" queda para los pares que siguen
siendo similares. Un par parecido nunca se presenta como identidad y no
se propone eliminar ni fusionar registros. Si stringdist no
está disponible, el resultado declara que la comparación no se ejecutó
en vez de fallar con un error críptico.
El diagnóstico de casi duplicados del vocabulario se ejecuta por columna. Dos columnas con el mismo vocabulario conservan filas de cobertura y hallazgos separados; el costo puede repetirse para no mezclar sus frecuencias ni su alcance.
Los duplicados exactos que informa perfilar() siguen
otra unidad: el hallazgo filas_duplicadas cuenta todas las
filas que participan en grupos duplicados y las enumera en la traza. La
evidencia conserva además cuántas son excedentes, porque esa cifra sólo
corresponde a la acción que conserva la primera fila.
Bloquear declarando la pérdida
Una clave indicada por el usuario puede bajar la constante del
trabajo, pero es con pérdida: dos filas cuya clave difiere no se
comparan. Las filas con NA forman un bloque propio y
también quedan contabilizadas. alcance informa los pares
fuera del alcance y su estimación sobre la muestra.
En perfilar(clave = ...), la unicidad y la ausencia de
nulos son comprobaciones separadas. meta$clave conserva el
estado de cada una cuando alguna no queda verificada: una colisión entre
NA puede ser útil para seguir la traza con la semántica de
R, pero no prueba una colisión de NULL en SQL; los ausentes
son la evidencia independiente contra NOT NULL.
Por eso la unicidad se evalúa sólo entre las filas con la
clave completa, y filas_evaluadas cuenta esas
filas frente a filas_totales. Si ninguna fila tiene la
clave completa, el estado no es verificada —sería cierto
sobre un conjunto vacío— sino sin_casos_evaluables. Una
clave sin ausentes y única no agrega ese metadato al perfil
histórico.
bloqueado <- detectar_duplicados_aproximados(
datos, columnas = c("nombre", "domicilio"), estrategia = "teselas",
bloquear_por = "anio", max_resultados = 100
)
bloqueado$alcance[, c(
"bloqueo_por", "bloqueo_pares_fuera_alcance",
"bloqueo_perdida", "bloqueo_severidad"
)]
#> bloqueo_por bloqueo_pares_fuera_alcance bloqueo_perdida bloqueo_severidad
#> 1 anio 24 TRUE sospechosoEl paquete no adivina una clave por su nombre ni por una jerarquía territorial: la columna la elige quien conoce el dato.
Lotes sin pérdida
lotes = TRUE escribe parciales en un directorio elegido
por el usuario (por omisión se usa uno temporal), cruza los grupos y une
la misma respuesta exacta. El objeto declara los archivos, bytes,
directorio y que los parciales no son reanudables. La comparación sigue
siendo exacta; sólo cambia cuánta memoria se usa a la vez.
dir <- tempfile("lupa-lotes-")
por_lotes <- detectar_duplicados_aproximados(
datos, columnas = c("nombre", "domicilio"), estrategia = "teselas",
lotes = TRUE, tamano_lote = 3, directorio_lotes = dir,
max_resultados = 100
)
por_lotes$lotes[c(
"directorio", "n_parciales", "bytes_totales", "reanudable", "perdida"
)]
#> $directorio
#> [1] "/tmp/Rtmp2toRgK/lupa-lotes-23324219160e/lupa-lotes-23326a917997"
#>
#> $n_parciales
#> [1] 6
#>
#> $bytes_totales
#> [1] 1519
#>
#> $reanudable
#> [1] FALSE
#>
#> $perdida
#> [1] FALSE
unlink(dir, recursive = TRUE)Para decidir si la operación cabe, se recomienda llamar primero a
estimar_costo() y establecer presupuesto_pares
en la detección. Así la decisión queda separada del recorrido y el
alcance permanece auditable.
Presupuestos que miden trabajo y no unidades
Un tope que cuenta unidades trata igual a una columna de códigos de diez caracteres y a una de WKT de mil. Una tabla del catálogo de PostGIS —3.912 filas, 5 columnas— tardaba 243 segundos, y el detector de vocabulario era el 99,6 % del costo: 800 valores distintos son 319.600 pares, muy por debajo del tope de dos millones, pero cada comparación era una Jaro-Winkler sobre 900 caracteres.
Por eso max_trabajo_vocabulario se mide en
comparaciones de carácter, que es el bucle interno de
la distancia: comparar dos valores de largos L1 y
L2 cuesta del orden de L1 × L2. Contar pares
por longitud media no alcanzaba —el mismo presupuesto compraba 5,3
millones de unidades por segundo con valores de 900 caracteres y 44
millones con valores de 40—.
El valor por omisión está calibrado contra la medición, no contra la intuición, y el criterio no fue sólo cortar el caso patológico sino no tocar el común:
| valores | largo | sin tope | con tope | comparado |
|---|---|---|---|---|
| 800 | 900 | 61,3 s | 4,6 s | 27,8 % |
| 2000 | 80 | 5,0 s | 5,1 s | 100 % |
Una columna corriente de dos mil valores se compara entera
mientras sus valores midan menos de unos cien
caracteres. El tope por trabajo muerde cuando
L² · n(n-1)/2 supera 2e10, así que para dos
mil valores distintos el corte está en 101 caracteres:
| valores | largo | ¿se recorta? | formas comparadas |
|---|---|---|---|
| 2000 | 80 | no | 2000 |
| 2000 | 100 | no | 2000 |
| 2000 | 101 |
sí, por max_trabajo
|
1980 |
| 2000 | 200 | sí, por max_trabajo
|
1000 |
Conviene tenerlo presente porque la columna que motivó el presupuesto —WKT de 900 caracteres— cae del lado recortado: la tranquilidad de «dos mil valores se comparan enteros» no alcanza a los datos que hicieron falta el tope.
Y lo que sí se recorta se declara, con las dos
cuentas separadas: cuántas formas normalizadas quedaron sin comparar y
cuánto trabajo eran, junto con cuál de los topes recortó, en el alcance
del hallazgo y en cobertura_diagnosticos. Si aprietan los
dos, el motivo los nombra a los dos, porque hay que poder elegir cuál
aflojar.
Un detalle que conviene saber antes de confiar en un recorte: las formas que sí se comparan son las primeras en orden alfabético, no una muestra ni las primeras en aparecer. Lo que queda afuera es el tramo final del alfabeto, y eso se declara.
Que sea el alfabeto y no el orden de llegada no es una preferencia de
estilo: tomarlas en orden de llegada hacía que el veredicto
dependiera de cómo viniera ordenado el archivo. Medido sobre la
columna nombre de Ejes de vías de circulación de
Montevideo —45.400 filas, 8.318 formas distintas, del catálogo nacional
de datos abiertos—, las mismas filas daban:
| orden de las filas | grupos de casi-duplicados |
|---|---|
| tal como viene el archivo | 26 |
| desordenado (tres semillas) | 70, 71, 85 |
| alfabético | 148 |
Un perfilador que da 26 o 148 según el orden de las filas está midiendo la forma física de la tabla y no los datos. Ordenando antes de recortar, los cinco órdenes dan 148.
El alfabeto además no es una elección arbitraria entre órdenes
estables: deja los casi-duplicados adyacentes
—CAMINO CARRASCO al lado de CAMINO AGRARIOS—,
así que el corte cae entre familias en vez de partirlas. Una muestra al
azar rompe pares: si de un grupo de dos sobrevive uno, el grupo
desaparece. Por eso el azar rinde 70–85 y el orden rinde 148 con el
mismo presupuesto.
detectar_dependencias() tiene el suyo,
max_trabajo, en unidades fila-par: ahí el
costo es del orden de columnas² × filas, y
max_comparaciones no lo veía —158 columnas son 24.806
pares, muy por debajo de las 200.000 del tope—. Se combina con
max_comparaciones y manda el más restrictivo. Desde
perfilar() los dos presupuestos se llaman
max_trabajo_vocabulario y
max_trabajo_dependencias, y Inf desactiva
cualquiera de ellos.
El banco benchmark/medir_costo_texto.R reproduce el
barrido completo, para que la calibración se pueda rehacer si cambia la
implementación de la distancia.
El recorte de pares, y por qué también se ordena por contenido
Hay un segundo recorte, y hasta acá lo veníamos usando sin mirarlo:
max_resultados, que en todos los ejemplos de arriba vale
100. Ese tope no decide qué formas se
comparan —eso era el presupuesto de vocabulario— sino
cuántos pares se devuelven una vez comparadas.
Conserva los más cercanos, ordenando por distancia. El problema aparecía entre pares empatados: ahí desempataba por posición de fila, así que cuáles sobrevivían al corte dependía del orden en que llegaron las filas.
No es un caso raro. Los empates son la regla cuando las variantes se parecen del mismo modo —un espacio de más, un sufijo societario, una letra caída—, porque la distancia sale idéntica. Medido sobre 60 grupos construidos para que sus 60 pares internos compartan exactamente la misma distancia, con el corte en 30:
| orden de las filas | grupos representados | en común con el natural |
|---|---|---|
| natural | 30 | — |
| inverso | 30 | 0 |
| barajado (tres semillas) | 30 | 0 |
Cinco órdenes, treinta grupos cada uno, ni uno compartido. Es el
mismo defecto que la sección anterior ya había encontrado y resuelto
para el recorte de formas, así que la respuesta es la misma:
ordenar por contenido. El desempate usa ahora el rango
canónico del valor, con una clave simétrica —min y
max del rango— para que tampoco dependa de cuál fila quedó
primera dentro del par. Los cinco órdenes devuelven exactamente los
mismos grupos.
El rango se calcula una sola vez sobre el universo completo de la corrida, no por lote ni por tesela: una numeración local volvería a hacer que la comparación entre lotes dependiera de cómo se repartieron.
Lo que ordenar no arregla
Un corte dentro de un empate deja afuera pares igual de cercanos que los que conserva. Eso no lo arregla ningún orden, y por eso se declara:
alcance$distancia_corte # la distancia donde cayó el corte
alcance$n_en_distancia_corte # cuántos de los conservados la comparten
alcance$corte_en_empate # TRUE si el corte cayó dentro de un empatecorte_en_empate no es truncado con
otro nombre: cuando el corte cae en una distancia única, da
FALSE aunque haya habido recorte. Si da TRUE,
el número de grupos que ve es un subconjunto de los que había —estable y
reproducible, pero subconjunto—, y subir max_resultados por
encima del empate los trae a todos.
Se mide contra lo que el recorte descartó, no contra lo que quedó: si en el borde sobrevive un solo par, contar los conservados daría 1 y la señal diría que no hubo empate, cuando puede haber tirado varios a esa misma distancia.
Y hay un límite que ningún orden saca: si varias filas comparten el valor comparado, el conjunto de pares de valores sale idéntico en cualquier orden, pero cuáles filas los representan cambia. Esas filas son indistinguibles en la columna que se compara, así que su única identidad es la posición —justo lo que varía al reordenar—. Si importa qué instancia se informa, hace falta una clave que las distinga.
Y una advertencia sobre el camino LSH
Con más de diez mil valores distintos entra MinHash/LSH, y ahí el conjunto de candidatos depende del orden de las filas: el vocabulario de q-gramas se numera por orden de primera aparición y esa numeración alimenta las firmas. Reordenar la tabla cambia qué pares se proponen.
Eso está dentro de la garantía declarada, no es un defecto: medido sobre 1.200 filas, de los 11.822 pares que cambian al barajar, el Jaccard de q-gramas más alto es 0,7857 y no se pierde ninguno por encima de 0,8, donde el alcance declara un recall de 0,9998. La rotación está entera en la cola de pares apenas parecidos, donde LSH dice que no promete nada.
Pero es una propiedad del resultado, así que el alcance la declara y no hay que venir a buscarla acá:
alcance$lsh_candidatos_dependen_orden_filas # TRUE bajo LSH
alcance$lsh_orden_vocabulario # "primera_aparicion"El segundo no es decorativo. Nombra el mecanismo: el vocabulario se numera por orden de primera aparición, y si alguna vez esa numeración se canoniza, ese valor cambia y la dependencia deja de declararse sola. Ninguno de los dos aparece en el camino exhaustivo, que no tiene esa dependencia.
