Un ejercicio de benchmark (y un generador de polígonos aleatorios)
En el artículo anterior aprendimos que calcular el área de un polígono simple, conociendo
las coordenadas de sus vértices, es un problema directamente resoluble, con ayuda de la
llamada fórmula shoelace. Suponiendo un data.frame
o matrix
de vértices, cuyas
columnas son colnames(.) = c("x", "y")
, la implementación en R de esta fórmula puede escribirse así:
shoelace_area = function(vertices){
x0 = vertices[, "x"]
x1 = x0[c(2 : length(x0), 1)]
y0 = vertices[, "y"]
y1 = y0[c(2 : length(y0), 1)]
sum(x0 * y1 - x1 * y0) / 2
}
También comprobamos que el área calculada de esta manera es correcta, coincidiendo con el
área calculada por una función confiable, i.e. la encontrada en el paquete sf
para
datos espaciales. No obstante, frente a dos funciones capaces de calcular un mismo
resultado, la pregunta inevitable es ¿cuál utilizar? Varios criterios deben ser
considerados pero, por lo pronto, digamos que nos interesa la velocidad de ejecución; en
este caso, el método benchmark nos permitirá evaluar ese criterio. TL; DR: nuestra
función es más rápida, pero es una ventaja insignificante, sin contar que sf::st_area
es mucho más versátil.
Una lección de geometría
Necesitaremos datos para realizar el benchmark, y ya que hablamos de polígonos, sería conveniente contar con una función que los genere aleatoriamente. Recordemos que un polígono es una figura plana finita, delimitada por una cadena cerrada (el límite) de segmentos lineales (los lados). Si el límite no se interseca a sí mismo, entonces el polígono es simple. Para generalizar la nomenclatura nos referiremos al polígono de n lados como un “n-gon”. Ahora, aunque no lo parezca, generar polígonos completamente aleatorios es un problema más o menos complicado. El siguiente método simplifica considerablemente este problema:
- Ubicar n puntos aleatorios sobre una circunferencia de radio definido.
- Ordenar los puntos en sentido antihorario, comenzando por cualquiera de ellos.
- Conectar los puntos en dicho orden, cerrando y rellenando el polígono.
Debido a que todos sus vértices yacen sobre la circunferencia circunscrita, el polígono así generado será cíclico. Pero no todos los polígonos poseen una circunferencia circunscrita; los siguientes ejemplos -no obtenibles con este método- lo demuestran:
Hasta aquí, los polígonos presentados, cíclicos o no, son todos convexos; significa que todos sus ángulos interiores son menores a 180º. Todos los polígonos cíclicos son convexos, pero lo contrario no es verdad. La principal propiedad de la convexidad es que, para dos puntos cualesquiera del polígono (en su interior o en su límite), el segmento que los conecta nunca sale al exterior del polígono. En contraste, los polígonos cóncavos (aquellos con al menos un ángulo interior mayor a 180º), carecen de esta propiedad. Una manera sencilla (aunque no infalible) de obtener polígonos cóncavos es insertar este paso en el método anterior:
2.5. Mover aleatoriamente los puntos hacia el centro de la circunferencia, con una cantidad no mayor al radio de la misma.
Dependiendo de la magnitud del acercamiento de los vértices al centro, los polígonos podrían o no dejar de ser convexos, pero siempre dejarán de ser cíclicos. Además, siempre serán estrellados. Un polígono estrellado posee al menos un punto en su interior desde el cual es posible observar todo el límite; en este método, al menos el centro de la circunferencia cumplirá esta propiedad. Todos los polígonos convexos son estrellados, pero no todos los cóncavos lo son; los siguientes ejemplos son cóncavos, pero no estrellados:
En definitiva, este método es una simplificación porque solamente puede producir polígonos estrellados; sin embargo, para nuestro propósito, es suficientemente simple y aleatorio. Con esto concluye la lección de geometría.
Un generador de polígonos
La siguiente función ngon
utiliza el método ya descrito para generar aleatoriamente un
polígono estrellado de n
lados, convexo o cóncavo, contenido en una circunferencia de
radius
específico centrada en el origen.
ngon = function(n, min_radius = NA, radius = 1){
stopifnot(is.integer(n), n > 2L)
stopifnot(is.numeric(radius), radius > 0)
if(!is.na(min_radius)){
stopifnot(is.double(min_radius), min_radius > 0, min_radius < 1)
radius = radius * runif(n, min_radius, 1)
}
theta = sort(runif(n, max = 2 * pi))
ngon = radius * matrix(c(cos(theta), sin(theta)), n)
colnames(ngon) = c("x", "y")
return(`class<-`(ngon, c("ngon", "matrix")))
}
El resultado predeterminado de ngon
sería un polígono cíclico, con circunferencia
circunscrita de radio unitario. La probabilidad de obtener un polígono cóncavo deriva del
argumento min_radius
que permite acercar los vértices al centro, hasta cierto radio
mínimo (como porcentaje del radio de la circunferencia). Tanto este acercamiento, como la
ubicación de los vértices en la circunferencia, son calculados con una distribución uniforme, vía runif
.
Para facilitar el trabajo más adelante, se han definido dos métodos1
para los objetos de clase ngon
(uno para graficar y otro para transformar a la clase
units
), y una función para transformar a la clase sfg
.
plot.ngon = function(x, pal = tmaptools::get_brewer_pal("Dark2", 15, plot=FALSE), ...){
plot.default(x, asp = 1, bty = "n", type = "n", xaxt = "n", yaxt = "n", ...)
polygon(x, col = sample(pal, 1), border = NA)
}
library(units)
set_units.ngon = function(x, value) units::set_units(
`class<-`(x, "matrix"), value, mode = "standard")
library(sf)
as_polygon = function(x) st_polygon(list(x[c(1:nrow(x), 1), ]))
Generando polígonos convexos
Es hora de jugar con nuestra función generadora de polígonos. Para esta muestra no se alteró
el comportamiento predeterminado de ngon
, así que con seguridad los polígonos serán convexos.
set.seed(57); par(mfrow = c(2, 3)) # la "semilla" determina cuáles polígonos obtendré
for(i in c(3:4L, 6L, 10L, 30L, 90L)) plot(ngon(i), xlab = paste0(i, "-gon"), ylab = "")
Una observación evidente es que, con un número suficientemente grande de vértices, un
polígono convexo será bastante similar a un círculo. Podemos aprovechar este fenómeno
para estimar pi
, recordando que el área de un círculo de radio unitario es pi
unidades cuadradas.
## [1] "3.1415915 [cm^2]"
Generando polígonos cóncavos
Ahora veremos qué clase de polígonos cóncavos podemos obtener. En esta muestra
(combinando varios n
y min_radius
) se verifica que, cuando los vértices son pocos y
el radio mínimo cercano a uno, es probable que el polígono no sea cóncavo. En el caso
contrario, con muchos vértices y radio mínimo cercano a cero, se genera un polígono literalmente estrellado.
Efectuar benchmark
Finalmente poseemos los datos y el conocimiento necesarios para evaluar la velocidad de ejecución de dos funciones que calculan áreas. En pocas palabras, el benchmark consiste en ejecutar cierta expresión (una función o algún fragmento pequeño de código) muchas veces, midiendo cada vez el tiempo invertido en ello. Obviamente el tiempo dependerá de la capacidad del procesador utilizado; sin embargo, la utilidad del benchmark reside en ejecutar no una, sino varias expresiones similares. Comparando los tiempos de ejecución se obtiene un criterio -que algunas veces resulta sufiente- para decidir cuál expresión es la mejor resolviendo cierto problema.
De regreso a la definición de ngon
, existen tres parámetros con los que se puede jugar
y evaluar si ocurre algún cambio. Sin embargo, como se verá, el radio de la circunferencia
circunscrita y la convexidad del polígono generado son irrelevantes; por esta razón se
estudió su comportamiento con la función ngon
solamente. Todos los benchmarks fueron
ejecutados con microbenchmark
.
Variando el radio
library(microbenchmark)
for(i in c(10, 30, 100)) assign(paste0("r", i), ngon(100L, radius = 10 ^ i))
radBmark = microbenchmark(shoelace_area(r10), shoelace_area(r30), shoelace_area(r100))
Cada argumento ingresado en la función microbenchmark
es una expresión a ser evaluada.
En este caso, las expresiones son shoelace_area
aplicada a tres polígonos cíclicos (con
radio2 creciente: 1010, 1030 y 10100). El resultado del benchmark es un objeto
de clase microbenchmark
, pero también un data.frame
. En consecuencia, podemos
manipular este objeto de muchas formas; por ejemplo, podemos utilizar dplyr
para descartar valores atípicos:
Lo siguiente son los resultados3 de print(radBmark)
y plot(radBmark)
. En el gráfico, el
tiempo se encuentra en nanosegundos. Es claro, de acuerdo al resumen númerico y al
gráfico, que aumentar el radio en ngon
no afecta el tiempo de cálculo.
## Unit: microseconds
## expr min lq mean median uq max neval
## shoelace_area(r10) 8.2 8.5 9.020 8.9 9.30 11.8 99
## shoelace_area(r30) 8.0 8.4 8.984 8.8 9.35 12.1 99
## shoelace_area(r100) 8.2 8.5 9.027 8.9 9.30 11.3 100
Variando la convexidad
for(i in c(9, 5, 1)) assign(paste0("r.", i), ngon(1000L, i / 10))
conBmark = microbenchmark(shoelace_area(r.9), shoelace_area(r.5), shoelace_area(r.1))
Esta vez, las expresiones evaluadas son shoelace_area
aplicada a tres polígonos
cóncavos, con valores min_radius
decrecientes: 0.9, 0.5 y 0.1. Así se sabrá si cambiar
la convexidad del polígono afecta el cálculo del área; nuevamente, el cambio resulta ser
irrelevante. Ambas variaciones, radio y convexidad, pueden introducir cambios abruptos en
la magnitud númerica de los vértices, pero esto es irrelevante para la manera como los
valores numéricos son tratados.
Variando el número de vértices
verBmark = setNames( , c(1e2L, 3e2L, 1e3L, 3e3L, 1e4L, 3e4L)) %>%
lapply(function(ver) microbenchmark(
shoelace_area(gon), st_area(sfg), times = 1e3,
setup = { gon = ngon(ver); sfg = as_polygon(gon) }
))
Podemos anticipar que incrementar el número de vértices sí afectará el tiempo de cálculo;
más que comparar entre polígonos, esta vez se compara el desempeño de las funciones
shoelace_area
y st_area
. Para esto se ejecutó seis benchmarks: en cada uno, ambas
funciones se ejecutaron mil veces, usando cada vez un nuevo polígono de 100 / 300 / mil /
3 mil / 10 mil / 30 mil vértices. Después de ejecutarlos, la lista de benchmarks fue
manipulada con dplyr
y el siguiente gráfico generado con ggplot2
(y se utilizó
violinplots en lugar de boxplots).
Es así como descubrimos que nuestra función shoelace_area
calcula el área en menos
tiempo. Resulta que con polígonos pequeños (en el sentido de tener pocos vértices),
st_area
toma mucho más tiempo que shoelace_area
, seguramente porque realiza
verificaciones sobre los datos u otra clase de operaciones. Esta diferencia se hace cada
vez más pequeña y tal vez desaparece, para un número suficientemente grande de vértices.
Conclusiones
En este artículo se habló de algunos conceptos de geometría y de cómo implementarlos en
código R, aparentemente con el propósito de generar polígonos y comparar dos funciones
que permiten calcular sus áreas. Pero la verdad es que el propósito siempre fue aprender
a realizar benchmarks. Desde el principio era previsible que sf::st_area
tuviese una
ejecución más larga, no solo porque debe verificar que los datos sean adecuados (de clase
sf
/ sfc
/ sfg
); también porque está preparada para tratar con los distintos tipos
de geometrías existentes en el análisis espacial. En el siguiente ejemplo, el área de la
geometría multipolygon no podría ser calculada con nuestra función.
## Units: [km^2]
## [1] 2392 3518 2680 3405 1465 5118 1443 2857 3103 1834 5053 2210
NLD_cent = unlist(st_centroid(NLD_prov)$geometry)
plot(NLD_prov["area"], main = "Netherlands provinces' area [km²]", reset = FALSE)
text(NLD_cent[2*1:12-1], NLD_cent[2*1:12], sub("-", "\n", NLD_prov$name), col = "grey")
Adicionalmente, ambas fórmulas se ejecutan con gran velocidad: cuando se calculó el área
de un 30000-gon, el tiempo promedio fue algo mayor a mil microsegundos (eso es la
milésima parte de un segundo). Una duda final: ¿en sf::st_area
se aplica la fórmula
shoelace, o existe un mejor algoritmo? Observando los tiempos de nuestra shoelace_area
,
no obstante, parece que sí es la solución más eficiente.
Un método es una versión de otra función -denominada genérica- que sirve para una clase específica. Por ejemplo,
diff
es una función genérica con métodos para números, fechas y fecha/horas. Compárese el resultado dediff(1:9)
, dediff(Sys.Date()+1:9)
y dediff(Sys.time()+1:9)
.↩︎El número 10100, conocido como “googol”, es mayor que el número estimado de partículas en el universo (~1080). Estos números son ridículamente gigantescos, pero para un procesador no poseen ningún significado. Es posible utilizar un radio mayor; el límite es el valor que la expresión
sqrt(.Machine$double.xmax/pi)
calcule.↩︎Las funciones
print
yplot
son genéricas. El primer resultado corresponde al métodomicrobenchmark
deprint
, y el segundo al métododata.frame
deplot
.↩︎