El objetivo de este manual es proveer la base necesaria para analizar y visualizar datos de Open Contracting usando el lenguaje de programación R a las personas interesadas en esta interesante y desafiante tarea. Un conocimiento básico de programación de computadoras será útil al leer este documento; sin embargo, se proveerá una guía suficiente para que lectores menos técnicos puedan también seguir el manual.
Como parte de esta guía, se obtendrán, limpiarán, analizarán y graficarán datos de Open Contracting de 4 países miembros de la Open Contracting Partnership: Paraguay, México, Uruguay y Colombia. Este documento fue escrito utilizando R Notebooks, una herramienta que permite integrar Markdown y código R de manera conveniente, pudiendo exportarse el resultado en formato HTML y PDF. El código fuente de este proyecto está disponible aquí.
El resto de este documento está organizado de la siguiente manera:
El resto de esta sección introduce el estándar de datos Open Contracting, describiendo brevemente sus principales elementos, y el lenguaje de programación R, cubriendo sus funcionalidades más importantes y como instalarlo.
La sección 2 trata de la adquisición y limpieza de datos, haciendo énfasis en la lectura y procesamiento de datos en formato JSON con R.
La sección 3 presenta el Tidyverse, una colección de paquetes de R diseñados para realizar tareas de ciencia de datos.
Finalmente, la sección 4 introduce ggplot2 y la gramática de gráficos en la cual está basado, para luego dibujar varias visualizaciones que describen los datasets adquiridos.
Al requerir que los datos se compartan de forma estructurada, reusable y legible por las máquinas, los datos abiertos abren nuevas oportunidades para la participación y el empoderamiento ciudadanos. El estándar de datos Open Contracting fue creado para aplicar estos principios al ciclo de vida de contrataciones públicas incluyendo la planificación, licitación, adjudicación, contratación y ejecución.
Este estándar de datos, diseñado y desarrollado a través de un proceso abierto, permite a los gobiernos alrededor del mundo compartir sus datos de contrataciones, mejorando así los niveles de transparencia en las compras públicas, y haciendo posible el análisis de los sistemas de contrataciones públicas en términos de eficiencia, efectividad, justicia e integridad. Además, el equipo de help desk está disponible para asistir a potenciales usuarios en el proceso de adopción del estándar.
La intención de esta sección es introducir el estándar al lector, los casos de uso para los cuales fue diseñado y los conceptos básicos necesarios para su aplicación. La mayoría del contenido fue obtenido de la documentación oficial del estándar; para una introducción más detallada del estándar, favor referirse a la guía para empezar.
El estándar fue diseñado con cuatro grupos de necesidades de usuario en mente:
Para saber quien está usando datos publicados de acuerdo al estándar y de que manera lo están haciendo, dirigirse al sitio web de la Open Contracting Partnership. Cuatro casos de uso potenciales de datos abiertos de contrataciones son:
El estándar define el proceso de contratación como:
Toda la información relativa a la planeación, la licitación, las adjudicaciones, los contratos y la ejecución de contratos relacionados con un solo proceso de iniciación.
El estándar cubre todas las etapas del proceso de contratación, aunque algunos procesos pueden no incluir todos los pasos posibles. Las etapas del proceso de contratación, con ejemplos de atributos que pueden estar asociados a cada una, se presentan en la figura 1.
Con fines de identificación, cada proceso de contratación tiene asignado un identificador único de Open Contracting (ocid), el cual puede utilizarse para relacionar datos de diferentes etapas. De modo a evitar solapamiento entre publicadores, un publicador puede incluir un prefijo a todos los identificadores que genera. Se recomienda a los publicadores registrar su prefijo aquí.
Los procesos de contratación son representados como documentos en el estándar de datos Open Contracting (OCDS de aquí en adelante, por brevedad). Cada documento está compuesto por varias secciones, mencionadas a continuación:
Un documento JSON de ejemplo con este estructura luce de la siguiente manera:
{
"language": "en",
"ocid": "contracting-process-identifier",
"id": "release-id",
"date": "ISO-date",
"tag": ["tag-from-codelist"],
"initiationType": "tender",
"parties": {},
"buyer": {},
"planning": {},
"tender": {},
"awards": [ {} ],
"contracts":[ {
"implementation":{}
}]
}
Existen dos tipos de documentos definidos en el estándar:
Entregas son inmutables y representan actualizaciones al proceso de contratación. Por ejemplo, pueden utilizarse para notificar a usuarios acerca de nuevas licitaciones, adjudicaciones, contratos y otras actualizaciones. De este modo, un único proceso de contratación puede tener muchas entregas.
Registros son instantáneas del estado actual del proceso de contratación. Un registro debe ser actualizado cada vez que se publica una entrega asociada al proceso de contratación; de este modo, debe existir un único registro por cada proceso de contratación.
Cada sección puede contener varios campos especificados en el estándar, los cuales se utilizan para representar datos. Estos elementos pueden aparecer varias veces en diferentes secciones del mismo documento; por ejemplo, pueden haber items en la licitación (para indicar items que un comprador desea comprar), en la adjudicación (para indicar items que han sido adjudicados) y en el contrato (para indicar items que forman parte del contrato). Algunos campos, acompañados de documentos JSON correspondientes, se presentan a continuación.
{
"address": {
"countryName": "United Kingdom",
"locality": "London",
"postalCode": "N11 1NP",
"region": "London",
"streetAddress": "4, North London Business Park, Oakleigh Rd S"
},
"contactPoint": {
"email": "procurement-team@example.com",
"faxNumber": "01234 345 345",
"name": "Procurement Team",
"telephone": "01234 345 346",
"url": "http://example.com/contact/"
},
"id": "GB-LAC-E09000003",
"identifier": {
"id": "E09000003",
"legalName": "London Borough of Barnet",
"scheme": "GB-LAC",
"uri": "http://www.barnet.gov.uk/"
},
"name": "London Borough of Barnet",
"roles": [ ... ]
}
{
"amount": 11000000,
"currency": "GBP"
}
{
"additionalClassifications": [
{
"description": "Cycle path construction work",
"id": "45233162-2",
"scheme": "CPV",
"uri": "http://cpv.data.ac.uk/code-45233162.html"
}
],
"classification": {
"description": "Construction work for highways",
"id": "45233130",
"scheme": "CPV",
"uri": "http://cpv.data.ac.uk/code-45233130"
},
"description": "string",
"id": "0001",
"quantity": 8,
"unit": {
"name": "Miles",
"value": {
"amount": 137000,
"currency": "GBP"
}
}
}
{
"endDate": "2011-08-01T23:59:00Z",
"startDate": "2010-07-01T00:00:00Z"
}
{
"datePublished": "2010-05-10T10:30:00Z",
"description": "Award of contract to build new cycle lanes to AnyCorp Ltd.",
"documentType": "notice",
"format": "text/html",
"id": "0007",
"language": "en",
"title": "Award notice",
"url": "http://example.com/tender-notices/ocds-213czf-000-00001-04.html"
}
{
"description": "A consultation period is open for citizen input.",
"dueDate": "2015-04-15T17:00:00Z",
"id": "0001",
"title": "Consultation Period"
}
Además de los campos comunes, el esquema OCDS define algunos campos que pueden usarse solamente en secciones específicas, por ejemplo titulos y descripciones de licitaciones, adjudicaciones y contratos. En algunos casos, los publicadores pueden requerir campos que no se proporcionan en el esquema central de OCDS; una extensión permite definir nuevos campos que pueden usarse en estos casos. Una lista de extensiones disponibles se encuentra publicada aquí; en caso de que no exista una extensión que se ajuste a las necesidades del publicador, se anima al mismo a colaborar en la creación de una nueva extensión comunitaria.
Otro concepto que vale la pena mencionar son las listas de códigos. Las listas de códigos son colecciones de cadenas con distinción entre mayúsculas y minúsculas con etiquetas asociadas, disponibles en cada idioma al que se ha traducido OCDS. Los publicadores deben utilizar valores de las listas de códigos cuando sea posible para mapear sus sistemas de clasificación actual; en caso de ser necesario, los campos de detalle pueden utilizarse para proveer información de clasificación más detallada. Existen dos tipos de listas de códigos:
El estándar de datos Open Contracting se mantiene usando JSON Schema. En esta sección introdujimos y describimos sus secciones principales y elementos comunes del esquema, proveyendo documentos JSON como ejemplos de estos bloques básicos. Para acceder a una referencia del esquema JSON completo, el lector puede dirigirse a la documentación oficial.
R es un lenguaje de programación interpretado multiparadigma y un ambiente de desarrollo software orientado a la computación estadística, utilizado comúnmente para análisis de datos. Se publica bajo la licencia GPL v2 y su mantenimiento está a cargo de la R Foundation, con paquetes binarios disponibles para GNU/Linux, macOS y Windows. Aunque el instalador básico incluye una interfaz por línea de comandos, varios ambientes de desarrollo integrados gráficos (IDE) se encuentran disponibles, entre los cuales RStudio y RStudio Server merecen mención especial1.
En esta sección introduciremos la sintaxis y algunas funcionalidades básicas de R; luego de esto, el lector estará mejor preparado para entender el código utilizado para el análisis del resto de esta guía. Una referencia completa del lenguaje R se encuentra fuera del alcance de este documento, razón por la cual varias funcionalidades se omiten en esta sección. Si el lector se sintiese inclinado a aprender más sobre R, por necesidad o curiosidad, una lista de manuales mantenidos por el equipo de desarrollo de R se encuentra disponible aquí.
Con propósitos de completitud y reproducibilidad, incluimos a continuación un fragmento de información de sistema.
R.version
## _
## platform x86_64-pc-linux-gnu
## arch x86_64
## os linux-gnu
## system x86_64, linux-gnu
## status
## major 3
## minor 4.4
## year 2018
## month 03
## day 15
## svn rev 74408
## language R
## version.string R version 3.4.4 (2018-03-15)
## nickname Someone to Lean On
Los operadores aritméticos y lógicos de R no deberían ser nada nuevo para la mayoría de los programadores, ya que son parecidos a los de otros lenguajes de programación. Merece mención el hecho de que los operadores aritméticos funcionan con escalares y colecciones.
Operador | Descripción |
---|---|
+ | Adición |
- | Substracción |
* | Multiplicación |
/ | División |
** or ^ | Exponenciación |
%% | Módulo |
Operador | Descripción |
---|---|
> | Mayor que |
>= | Mayor o igual que |
< | Menor que |
<= | Menor o igual que |
== | Igual a |
!= | Diferente a |
R soporta varios tipos de datos incluyendo escalares, vectores (de números, cadenas, booleanos, etc), matrices, dataframes y tibbles, entre otros. El operador <- se usa para asignar un valor a una variable. Algunos ejemplos de variables de estos tipos se muestran a continuación:
a_scalar <- 4
a_number_vector <- c(1, 2, 3, 4, 5) # todos los elementos de un vector deben ser del mismo tipo
a_string_vector <- c("a1", "b2", "c3", "d4")
a_boolean_vector <- c(TRUE, FALSE)
# las listas pueden tener elementos de distintos tipos, asociados a una clave
a_list <- list(name = "John", last_name = "Deer", age = 42, married = FALSE)
# hay varias formas de acceder a un elemento de una colección
a_number_vector[0]
## numeric(0)
a_string_vector[[1]]
## [1] "a1"
a_list$name
## [1] "John"
# una matriz es un tipo especial de vector, con número de filas y columnas como atributos
m <- matrix(c(1, 2, 3, 4), nrow = 2, ncol = 2)
m
## [,1] [,2]
## [1,] 1 3
## [2,] 2 4
Los tipos de datos para almacenar tablas son centrales al propósito y funcionalidad de R, por este motivo merecen su propia subsección. El tipo más común de tabla en R son los data frames, los cuales pueden considerarse como una lista de vectores de igual longitud.
name <- c("Mike", "Lucy", "John")
age <- c(20, 25, 30)
student <- c(TRUE, FALSE, TRUE)
df <- data.frame(name, age, student)
df
## name age student
## 1 Mike 20 TRUE
## 2 Lucy 25 FALSE
## 3 John 30 TRUE
Los operadores de acceso presentados para otros tipos de datos también pueden usarse para obtener celdas de un data frame.
df[1, ] # En R, las colecciones empiezan con el índice 1.
## name age student
## 1 Mike 20 TRUE
df[1, ]$name # Los valores de una fila puede accederse usando el nombre de la columna.
## [1] Mike
## Levels: John Lucy Mike
df$name # También es posible acceder a una columna directamente a partir del data frame.
## [1] Mike Lucy John
## Levels: John Lucy Mike
Otro tipo de tabla de datos disponible son los Tibbles. Los Tibbles pertenecen al Tidyverse, un conjunto de librerías para realizar análisis de datos en R siguiendo buenas prácticas que se describirá con más detalle en otra sección. Por ahora, basta pensar en los Tibbles como data frames con algunos problemas solucionados. Por ejemplo, al imprimir un data frame se despliegan todas las filas en pantalla, lo cual puede resultar problemático para datasets grandes; en cambio, al imprimir un Tibble se despliegan solamente las primeras 10 filas por defecto.
Un data frame puede convertirse a Tibble y viceversa; este último caso puede ser útil al lidiar con librerías antiguas que no estén adaptadas al Tidyverse. El autor aconseja el uso de tibbles y recomienda (como muchos miembros de la comunidad) aprovechar las funcionalidades del Tidyverse y respetar sus delineamientos tanto como sea posible.
# Packrat debe haber instalado las dependencias del proyecto,
# caso contrario, puedes hacerlo manualmente con el siguiente comando
# install.packages("tidyverse")
library(tidyverse) # Nota: así se importa un paquete en R
tb <- as_tibble(df)
class(as.data.frame(tb))
## [1] "data.frame"
La sintaxis para definición de funciones en R es bastante similar a la de otros lenguajes de programación. Una función recibe cero, uno o múltiples argumentos y ejecuta el código incluido en su cuerpo:
function ( arglist ) {body}
Debido a que R apunta a un nicho específico (computación estadística), el lenguaje ofrece un conjunto variado de funciones por defecto, además de muchas librerías disponibles para su instalación. Por este motivo, las llamadas a funciones tienden a ser mucho más frecuentes en R que la definición de funciones.
La mayor parte del ecosistema de R está enfocado en tratar (limpiar, visualizar, modelar) datos tabulares. Como ejemplo sencillo de las funcionalidades estadísticas básicas podemos usar la función summary para obtener estadísticas descriptivas de una tabla.
summary(tb)
## name age student
## John:1 Min. :20.0 Mode :logical
## Lucy:1 1st Qu.:22.5 FALSE:1
## Mike:1 Median :25.0 TRUE :2
## Mean :25.0
## 3rd Qu.:27.5
## Max. :30.0
Leer datos que se encuentran en formato tabular también es muy simple, como se muestra en el siguiente ejemplo que lee un archivo CSV como un data frame. El archivo corresponde al dataset iris, un dataset de ejemplo común en la literatura de ciencia de datos disponible en línea.
iris <- as_tibble(read.table("iris.csv", header = TRUE, sep = ","))
iris
## # A tibble: 150 x 5
## SepalLength SepalWidth PetalLength PetalWidth Name
## <dbl> <dbl> <dbl> <dbl> <fct>
## 1 5.1 3.5 1.4 0.2 Iris-setosa
## 2 4.9 3 1.4 0.2 Iris-setosa
## 3 4.7 3.2 1.3 0.2 Iris-setosa
## 4 4.6 3.1 1.5 0.2 Iris-setosa
## 5 5 3.6 1.4 0.2 Iris-setosa
## 6 5.4 3.9 1.7 0.4 Iris-setosa
## 7 4.6 3.4 1.4 0.3 Iris-setosa
## 8 5 3.4 1.5 0.2 Iris-setosa
## 9 4.4 2.9 1.4 0.2 Iris-setosa
## 10 4.9 3.1 1.5 0.1 Iris-setosa
## # ... with 140 more rows
Un último operador que vale la pena mencionar es el pipe %>%. Este operador permite encadenar funciones en R, lo cual favorece la legibilidad y (podría decirse) elegancia cuando se requiere una secuencia de llamadas a función.
# Por ahora, no es necesario entender que hace cada función en este fragmento de código
iris %>%
group_by(Name) %>%
summarize_if(is.numeric, mean) %>%
ungroup()
## # A tibble: 3 x 5
## Name SepalLength SepalWidth PetalLength PetalWidth
## <fct> <dbl> <dbl> <dbl> <dbl>
## 1 Iris-setosa 5.01 3.42 1.46 0.244
## 2 Iris-versicolor 5.94 2.77 4.26 1.33
## 3 Iris-virginica 6.59 2.97 5.55 2.03
El operador pipe pasa el valor de su operando izquierdo como el primer argumento de su operando derecho. De esta forma, al usarlo podemos evitar la declaración de variables para almacenar resultados intermedios (que contaminan el espacio de nombres) o el anidamiento de llamadas (que pueden ser difíciles de leer con tantos paréntesis).
Como observación final, aunque no existe una guía de estilo oficial para el lenguaje de programación R, el autor recomienda seguir la guía de estilo del Tidyverse2(lo cual no debería ser una sorpresa para el lector atento). El paquete styler es un linter y formateador de código conveniente para mantener el código fuente de acuerdo a los delineamientos de la guía; existe un add-in para RStudio disponible.
Tanto R como RStudio están disponibles para los sistemas operativos más populares, incluyendo Windows, MacOS y varias distribuciones de Linux. Ya que no es posible mostrar instrucciones por cada sistema existente, en esta sección mostraremos instrucciones para Windows y Ubuntu 18.04 Bionic Beaver. Usuarios de otros sistemas operativos podrán encontrar instrucciones en los sitios oficiales de R y RStudio.
Instalar R y RStudio en Windows es fácil: descarga y ejecuta los instaladores correspondientes desde aquí y aquí. Recuerda instalar R primero.
En Ubuntu 18.04 Bionic Beaver el proceso contiene más pasos:
sudo apt update
sudo apt -y install r-base
sudo apt install gdebi-core
sudo gdebi rstudio-xenial-1.1.456-amd64.deb
RStudio debería estar disponible para su ejecución mediante el comando rstudio o clickeando su ícono en el menú de aplicaciones.
Además de RStudio Desktop, el cual puede instalarse siguiendo los pasos anteriores, RStudio también se encuentra disponible en una versión de acceso remoto. RStudio Server es una aplicación que puede instalarse en un servidor web, ofreciendo de esta manero las mismas funcionalidades que la versión de escritorio a través de un navegador web. Para saber más acerca de esta alternativa y como instalarla, favor referirse a la documentación oficial.
El proyecto a partir del cual se generó este documento usa Packrat para la gestión de librerías de las cuales depende. Esto asegura la portabilidad del software (al importar el proyecto, se instalará la versión apropiada para el sistema operativo de cada paquete) y la reproducibilidad del análisis. Así, puedes bajar y jugar con este notebook de forma fácil:
git clone https://github.com/open-contracting/ocds-r-manual.git
Si usas Ubuntu 18.04, instala las siguientes librerías:
sudo apt-get install libxml2-dev curl libcurl4-openssl-dev libssl-dev libpq-dev g++
También usaremos una base de datos Postgres para nuestro análisis, por lo cual debemos instalar Postgres en caso de que no se encuentre en nuestro sistema. En Ubuntu 18.04, podemos hacer esto ejecutando:
sudo apt install postgresql postgresql-contrib
El último comando instala el motor de bases de datos Postgres y crea un usuario postgres a ser utilizado como cuenta por defecto para el acceso a bases de datos.
En Windows, puedes descargar el instalador desde el sitio oficial de descargas. El instalador incluye pgAdmin, una herramienta gráfica útil para la administración de bases de datos en PostgreSQL.
Esta sección se enfoca en la lectura de datos a partir de diversas fuentes, el cual representa el primer paso para el proceso de análisis. Se cargarán datos de los cuatro países que conciernen a este documento, usándolos como ejemplos de distintos métodos de adquisición de datos:
En los casos de Paraguay, México y Colombia usaremos datos preparados para su uso específico en el presente manual. Es importante notar que estos datos pueden no estar actualizados; por lo tanto, si el lector está interesado en analizar datos de estos países debe dirigirse a los publicadores originales:
El dataset de open contracting de Uruguay se encuentra disponible a través de un endpoint RSS, el cual provee datos desde diciembre del 2017 en adelante. Cabe mencionar que, como el conjunto de datos disponible a la fecha de publicación del presente manual no es lo suficientemente grande, un análisis en profundidad de este dataset no es posible. Aún así, es un ejemplo apropiado para mostrar las funcionalidades de cliente HTTP de R. En el siguiente ejercicio, recuperaremos las URLs de los releases de los últimos 3 meses.
library(XML)
library(dplyr)
# usamos el primer dia del mes para evitar posibles errores al calcular el rango de meses
firstDayOfCurrentMonth <- as.Date(format(Sys.Date(), "%Y-%m-01"))
date_range = seq(firstDayOfCurrentMonth, length=3, by="-1 month")
fetch_data <- function(d){
xml_url <- paste("http://www.comprasestatales.gub.uy/ocds/rss",
format(d, "%Y"),
format(d, '%m'),
sep = "/")
xml_file <- xmlParse(xml_url)
xml_top <- xmlRoot(xml_file)[[1]]
return(as_tibble(xmlToDataFrame(xmlElementsByTagName(xml_top, "item"),
stringsAsFactors=FALSE)))
}
xml_data_bymonth = lapply(date_range, fetch_data)
index_tb = bind_rows(xml_data_bymonth)
index_tb
## # A tibble: 30,383 x 5
## title pubDate link guid category
## <chr> <chr> <chr> <chr> <chr>
## 1 id_compra:694189… Thu, 20 Dec… http://www.comprases… ajuste_a… awardUp…
## 2 id_compra:697126… Thu, 20 Dec… http://www.comprases… llamado-… tender
## 3 id_compra:688727… Thu, 20 Dec… http://www.comprases… adjudica… award
## 4 id_compra:697049… Thu, 20 Dec… http://www.comprases… adjudica… award
## 5 id_compra:697111… Thu, 20 Dec… http://www.comprases… adjudica… award
## 6 id_compra:697084… Thu, 20 Dec… http://www.comprases… adjudica… award
## 7 id_compra:697101… Thu, 20 Dec… http://www.comprases… aclar_ll… tenderU…
## 8 id_compra:695915… Thu, 20 Dec… http://www.comprases… aclar_ll… tenderU…
## 9 id_compra:696488… Thu, 20 Dec… http://www.comprases… aclar_ll… tenderU…
## 10 id_compra:696437… Thu, 20 Dec… http://www.comprases… aclar_ll… tenderU…
## # ... with 30,373 more rows
En el fragmento anterior, empezamos importando la librería XML de R debido a que las respuestas de la API de Uruguay se encuentran en este formato. Ya que la API recibe mes y año como parámetros, calculamos una secuencia de fechas para representar los tres últimos meses y construimos una función para iterar a través de las fechas usando la función lapply.
Dentro de la función de iteración, el método xmlParse obtiene el contenido del feed RSS y lo parsea, retornando un array anidado que representa un árbol. Procedemos entonces a obtener la raíz de este árbol y buscar cada elemento con la etiqueta item usando el método xmlElementsByTagName. Convertimos el resultado a un dataframe y posteriormente a un tibble usando xmlToDataFrame y as_tibble respectivamente.
La función lapply retorna una lista de tibbles. Para unir los tibbles en uno solo, usamos la función bind_rows de la librería dplyr.
Procedemos luego a obtener el documento JSON correspondiente a cada entrega, usando los valores de la columna link del tibble definido con anterioridad. Existen muchas librerías para manejar documentos JSON con R, cada una con sus ventajas y desventajas; debido a su énfasis en el rendimiento y consumición de APIs web, en esta guía usamos jsonlite.
library(jsonlite)
# corremos fromJSON (que obtiene el contenido de la URL) por cada fila del data frame
releases <- apply(index_tb["link"], 1, function(r) {
return(as_tibble(fromJSON(r, flatten = TRUE)[["releases"]]))
})
releases[[1]]
## # A tibble: 1 x 10
## ocid id date tag language initiationType parties awards buyer.name
## * <chr> <chr> <chr> <lis> <chr> <chr> <list> <list> <chr>
## 1 ocds… ajus… 2018… <chr… es tender <data.… <data… Secretarí…
## # ... with 1 more variable: buyer.id <chr>
La principal función del fragmento de código anterior es fromJSON, la cual es proveida por jsonlite. La utilizamos para obtener los documentos JSON a partir de la url contenida en la columna link de index_tb. La bandera flatten indica que queremos que la función aplane lo más posible la estructura de los JSON anidados, dejándolos en un formato más apropiado para su representación tabular. Repetimos este proceso por cada file del tibble usando la función apply, la cual ejecuta la función que recibe como parámetro por cada file y retorna una lista como resultado.
En este punto tenemos una lista de tibbles, cada uno representando una entrega. El siguiente paso es unir estos tibbles, rellenando los valores faltantes (por ejemplo, de acuerdo a la etapa a la que corresponden, algunas entregas contienen información de licitación mientras otras no) con NA (la constante de R que simboliza ‘no disponible’). Podemos lograr esto usando la función bind_rows otra vez.
releases_tb <- bind_rows(releases)
Como se ha mencionado anteriormente, este dataset no es lo suficientemente grande aún para un análisis detallado; por lo tanto, este fue el último uso de los datos uruguayos en esta guía. Sin embargo, el ejercicio anterior puede ser fácilmente modificado para realizar un análisis en el futuro.
También podemos leer registros o entregas a partir de un conjunto de archivos JSON, publicados por uno o más publicadores oficiales asociados a un país. Este es el caso de Paraguay, el siguiente país que añadiremos a nuestro análisis. Como hemos dicho anteriormente, los datos abiertos de contrataciones de este país son publicados por la Dirección Nacional de Contrataciones Públicas (DNCP) y por el Ministerio de Hacienda; como comparten ocids, es posible usar los datos publicados por ambos de forma conjunta.
Para continuar con esta guía es necesario obtener una copia de los datasets y almacenarla en el disco duro local, bajo el directorio ./data (el cual se encuentra vacío luego de clonar el proyecto). Esto es:
Antes de leer los datos, tomémonos un tiempo para hacer que nuestro procesamiento de datos sea más eficiente. El tamaño de los datasets que analizamos en esta guía va desde no trivial (el procesamiento toma unos pocos minutos) hasta desafiante (el procesamiento toma varias horas). Esto se agrava si consideramos que R se ejecuta en un único hilo por defecto, desaprovechando los múltiples cores que ofrecen la mayoría de las computadoras modernas. Para cambiar esto, podemos usar la librería parallel, la cual permite ejecutar funciones como apply y similares de forma paralela.
library(glue) # Utilitario para concatenar cadenas
library(parallel)
library(lubridate) # si tienes que trabajar con fechas, hazte el favor de usar esta librería
# Calculamos el número de cores
no_cores <- detectCores() - 1
# Iniciamos el cluster
cl <- makeCluster(no_cores)
clusterExport(cl, c("fromJSON", "paste", "as_tibble", "ymd_hms", "select"))
Para empezar a usar parallel, definimos un cluster de tamaño igual al número de cores disponibles en nuestra máquina menos uno (si no dejamos ningún core libre para otro cómputo la computadora puede dejar de responder) con la función makeCluster. Cada uno de los trabajadores de nuestro cluster ejecuta un intérprete de R sin ninguna otra dependencia; para importar las librerías que utilizaremos usamos la función clusterExport.
Ahora estamos listos para empezar con los datos de la DNCP, los cuales están almacenados en data/records_dncp:
files <- list.files("data/records_dncp/", "*.json")
records <- parLapply(cl, files, function(r) {
file <- paste("data/records_dncp/", r, sep = "")
parsed <- fromJSON(file, flatten = TRUE)
records <- parsed[["records"]]
publishedDate <- ymd_hms(parsed[['publishedDate']])
records$publishedDate <- rep(publishedDate, nrow(records))
return(as_tibble(records))
})
records[[1]]
## # A tibble: 1 x 39
## releases ocid compiledRelease… compiledRelease… compiledRelease…
## * <list> <chr> <list> <chr> <list>
## 1 <data.f… ocds… <list [0]> ocds-03ad3f-134… <list [0]>
## # ... with 34 more variables: compiledRelease.date <chr>,
## # compiledRelease.language <chr>, compiledRelease.initiationType <chr>,
## # compiledRelease.id <chr>, compiledRelease.tag <list>,
## # compiledRelease.planning.url <chr>,
## # compiledRelease.planning.budget.description <chr>,
## # compiledRelease.planning.budget.amount.amount <lgl>,
## # compiledRelease.planning.budget.amount.currency <chr>,
## # compiledRelease.buyer.name <chr>,
## # compiledRelease.buyer.contactPoint.email <chr>,
## # compiledRelease.buyer.contactPoint.name <chr>,
## # compiledRelease.buyer.contactPoint.telephone <chr>,
## # compiledRelease.tender.title <chr>,
## # compiledRelease.tender.hasEnquiries <lgl>,
## # compiledRelease.tender.submissionMethod <list>,
## # compiledRelease.tender.lots <list>,
## # compiledRelease.tender.documents <list>,
## # compiledRelease.tender.status <chr>, compiledRelease.tender.id <chr>,
## # compiledRelease.tender.items <list>, compiledRelease.tender.url <chr>,
## # compiledRelease.tender.procuringEntity.name <chr>,
## # compiledRelease.tender.procuringEntity.contactPoint.email <chr>,
## # compiledRelease.tender.procuringEntity.contactPoint.name <chr>,
## # compiledRelease.tender.procuringEntity.contactPoint.telephone <chr>,
## # compiledRelease.tender.awardPeriod.endDate <lgl>,
## # compiledRelease.tender.awardPeriod.startDate <chr>,
## # compiledRelease.tender.enquiryPeriod.endDate <chr>,
## # compiledRelease.tender.tenderPeriod.endDate <chr>,
## # compiledRelease.tender.tenderPeriod.startDate <lgl>,
## # compiledRelease.tender.value.amount <lgl>,
## # compiledRelease.tender.value.currency <chr>, publishedDate <dttm>
El anterior fragmento de código usa varias de las funciones que ya utilizamos como fromJSON, as_tibble y otras. Hay tres nuevas funciones que mereces una explicación:
dncp_records_tb <- bind_rows(records)
remove(records)
dncp_records_tb
## # A tibble: 138,398 x 39
## releases ocid compiledRelease… compiledRelease… compiledRelease…
## <list> <chr> <list> <chr> <list>
## 1 <data.f… ocds… <list [0]> ocds-03ad3f-134… <list [0]>
## 2 <data.f… ocds… <list [0]> ocds-03ad3f-134… <list [0]>
## 3 <data.f… ocds… <data.frame [1 … ocds-03ad3f-134… <data.frame [1 …
## 4 <data.f… ocds… <list [0]> ocds-03ad3f-134… <list [0]>
## 5 <data.f… ocds… <data.frame [1 … ocds-03ad3f-134… <data.frame [1 …
## 6 <data.f… ocds… <NULL> <NA> <NULL>
## 7 <data.f… ocds… <NULL> <NA> <NULL>
## 8 <data.f… ocds… <data.frame [1 … ocds-03ad3f-136… <data.frame [3 …
## 9 <data.f… ocds… <data.frame [1 … ocds-03ad3f-136… <data.frame [1 …
## 10 <data.f… ocds… <list [0]> ocds-03ad3f-136… <list [0]>
## # ... with 138,388 more rows, and 34 more variables:
## # compiledRelease.date <chr>, compiledRelease.language <chr>,
## # compiledRelease.initiationType <chr>, compiledRelease.id <chr>,
## # compiledRelease.tag <list>, compiledRelease.planning.url <chr>,
## # compiledRelease.planning.budget.description <chr>,
## # compiledRelease.planning.budget.amount.amount <dbl>,
## # compiledRelease.planning.budget.amount.currency <chr>,
## # compiledRelease.buyer.name <chr>,
## # compiledRelease.buyer.contactPoint.email <chr>,
## # compiledRelease.buyer.contactPoint.name <chr>,
## # compiledRelease.buyer.contactPoint.telephone <chr>,
## # compiledRelease.tender.title <chr>,
## # compiledRelease.tender.hasEnquiries <lgl>,
## # compiledRelease.tender.submissionMethod <list>,
## # compiledRelease.tender.lots <list>,
## # compiledRelease.tender.documents <list>,
## # compiledRelease.tender.status <chr>, compiledRelease.tender.id <chr>,
## # compiledRelease.tender.items <list>, compiledRelease.tender.url <chr>,
## # compiledRelease.tender.procuringEntity.name <chr>,
## # compiledRelease.tender.procuringEntity.contactPoint.email <chr>,
## # compiledRelease.tender.procuringEntity.contactPoint.name <chr>,
## # compiledRelease.tender.procuringEntity.contactPoint.telephone <chr>,
## # compiledRelease.tender.awardPeriod.endDate <chr>,
## # compiledRelease.tender.awardPeriod.startDate <chr>,
## # compiledRelease.tender.enquiryPeriod.endDate <chr>,
## # compiledRelease.tender.tenderPeriod.endDate <chr>,
## # compiledRelease.tender.tenderPeriod.startDate <chr>,
## # compiledRelease.tender.value.amount <dbl>,
## # compiledRelease.tender.value.currency <chr>, publishedDate <dttm>
Para liberar un poco de espacio, usamos la función remove que elimina explícitamente una variable de memoria. Los datos del Ministerio de Hacienda pueden leerse de forma similar:
files <- list.files("data/releases_mh/", "*.json")
releases <- parLapply(cl, files, function(r) {
file <- paste("data/releases_mh/", r, sep = "")
parsed <- fromJSON(file, flatten = TRUE)
releases <- parsed[["releases"]]
publishedDate <- ymd_hms(parsed[['publishedDate']])
# hay archivos que no incluyen ninguna entrega
if (!is.null(nrow(releases)) && nrow(releases) > 0) {
releases$publishedDate <- rep(publishedDate, nrow(releases))
}
return(as_tibble(releases))
})
mh_releases_tb <- bind_rows(releases)
remove(releases)
mh_releases_tb
## # A tibble: 25,839 x 27
## recordPackageURI initiationType language contracts id tag parties
## <chr> <chr> <chr> <list> <chr> <lis> <list>
## 1 https://www.con… tender es <data.fr… 1567… <chr… <data.…
## 2 https://www.con… tender es <data.fr… 1848… <chr… <data.…
## 3 https://www.con… tender es <data.fr… 1881… <chr… <data.…
## 4 https://www.con… tender es <data.fr… 1881… <chr… <data.…
## 5 https://www.con… tender es <data.fr… 1888… <chr… <data.…
## 6 https://www.con… tender es <data.fr… 1890… <chr… <data.…
## 7 https://www.con… tender es <data.fr… 1891… <chr… <data.…
## 8 https://www.con… tender es <data.fr… 1898… <chr… <data.…
## 9 https://www.con… tender es <data.fr… 1911… <chr… <data.…
## 10 https://www.con… tender es <data.fr… 1911… <chr… <data.…
## # ... with 25,829 more rows, and 20 more variables: date <chr>,
## # awards <list>, ocid <chr>, planning.documents <list>,
## # planning.budget.project <chr>, planning.budget.budgetBreakdown <list>,
## # planning.budget.description <chr>,
## # planning.budget.amount.currency <chr>,
## # planning.budget.amount.amount <dbl>, tender.id <chr>,
## # tender.procurementMethodDetails <chr>, tender.title <chr>,
## # tender.procuringEntity.id <chr>, tender.procuringEntity.name <chr>,
## # tender.tenderPeriod.startDate <chr>,
## # tender.tenderPeriod.maxExtentDate <lgl>, buyer.id <chr>,
## # buyer.name <chr>, publishedDate <dttm>, value <lgl>
Para México y Colombia procesaremos los datos en modo streaming; esto es, un registro a la vez. Los dataframes y tibbles se almacenan por completo en memoria, en cambio el procesamiento de un stream requiere mantener solo un registro en memoria. Por esta razón, este paradigma de procesamiento de datos resulta útil al lidiar con big data.
Para continuar con esta guía, debes descargar los archivos de backup de estas bases de datos a tu computadora y restaurarlos en tu instancia local de Postgres. Para hacerlo:
sudo -u postgres createdb ocds_colombia
y sudo -u postgres createdb ocds_mexico
desde una terminal.pg_restore -d ocds_colombia ocds_colombia_dump
y pg_restore -d ocds_mexico ocds_mexico.dump
.data
debe contener los registros para nuestro análisis.La librería que utilizamos (jsonlite) incluye la función stream_in para soportar ndjson, un formato conveniente para almacenar múltiples documentos JSON en un único archivo. Como nuestros registros están almacenados en una base de datos Postgres, debemos escribir una funcionalidad similar nosotros mismos. Hagamos exactamente esto en los próximos fragmentos de código:
library(RPostgreSQL)
db_engine <- "PostgreSQL"
host <- "localhost"
user <- "postgres" # si te preocupa la seguridad
password <- "postgres" # deberías cambiar estas 2 líneas
port <- 5432
query <- "SELECT id, data FROM data"
drv <- dbDriver(db_engine)
con_colombia <- dbConnect(drv, host = host, port = port,
dbname = "ocds_colombia", user = user, password = password)
con_mexico <- dbConnect(drv, host = host, port = port,
dbname = "ocds_mexico", user = user, password = password)
Nos conectaremos y realizaremos consultas a Postgres a través de DBI, una definición de interfaz para comunicaciones entre R y motores de bases de datos relacionales. En particular, usaremos RPostgreSQL, una implementación de DBI para bases de datos Postgres.
stream_in_db <- function(con, query, page_size = 1000, acc = 0) {
current_id <- 0
return(function(handler) {
repeat{
paged_query <- paste(query, "WHERE id > ", current_id, "ORDER BY id ASC LIMIT", page_size)
data <- dbGetQuery(con, paged_query)
if (dim(data)[1] == 0) {
break
}
acc <- handler(data[['data']], acc)
current_id <- tail(data[['id']], n=1)
}
return(acc)
})
}
stream_in_colombia <- stream_in_db(con_colombia, query)
stream_in_mexico <- stream_in_db(con_mexico, query)
Repasemos lo que acabamos de hacer:
El desempeño de la paginación de resultados usando LIMIT and OFFSET se degrada conforme visitamos páginas más alejadas del inicio. Para acelarar el procesamiento lo más posible usamos una técnica de paginación conocida como keyset pagination, la cual aprovecha el hecho de que existe un índice definido para la clave primaria. Para una comparación más detallada de métodos de paginación se recomienda leer este artículo.
Para probar nuestras funciones de streaming, podemos definir un handler de prueba que solo cuente el número de filas que resultan de nuestra consulta:
sanity_checker <- function(data, acc) {
m <- parLapply(cl, data, function(e) {
t <- fromJSON(e, flatten = TRUE)
return(1)
})
return(acc + Reduce("+", m))
}
Esta sección introduce un conjunto de herramientas útiles para realizar análisis de datos usando R, acompañados de algunos ejemplos básicos para demostrar sus funcionalidades.
El Tidyverse es una colección de paquetes R orientados a las tareas de ciencia de datos; además de las librerías, comparte un conjunto de estructura de datos, una guía de estilo y una filosofía subyacente para el análisis de datos. Los principales paquetes del Tidyverse son:
Los tres primeros paquetes son los más importantes para los propósitos de esta guía. Por este motivo, tidyr y dplyr se cubrirán en el resto de esta sección y ggplot2 se describirá en la siguiente.
De acuerdo a la filosofía del Tidyverse, la limpieza de datos es el proceso de hacer que los datos sean ordenados. En un conjunto de datos ordenado:
Todos los paquetes del Tidyverse están diseñados para trabajar con datos ordenados; por tanto, al lidiar con un dataset desordenado, el primer paso de nuestro análisis deber ser usar tidyr para limpiarlo. Existen tres verbos principales proveidos por este paquete para ayudarnos a limpiar los datos: gather, spread y separate.
Un inconveniente frecuente en los datos desordenados es un dataset en el cual los nombres de las columnas no son nombres de variables sino valores. A modo de ejemplo, considera el siguiente fragmento:
world_population = tibble(
country = c("Paraguay", "Uruguay", "Colombia", "Mexico"),
"2017" = c(7000000, 3000000, 45000000, 127000000),
"2018" = c(7200000, 3200000, 46000000, 128000000),
)
world_population
## # A tibble: 4 x 3
## country `2017` `2018`
## <chr> <dbl> <dbl>
## 1 Paraguay 7000000 7200000
## 2 Uruguay 3000000 3200000
## 3 Colombia 45000000 46000000
## 4 Mexico 127000000 128000000
En el ejemplo anterior, los datos están desordenados porque 2017 y 2018 son valores de la variable implícita año. Esto implica que cada fila corresponde a dos observaciones, no a una. Podemos solucionar este problema aplicando la función gather como sigue:
world_population %>% gather(`2017`, `2018`, key = "year", value = "inhabitants")
## # A tibble: 8 x 3
## country year inhabitants
## <chr> <chr> <dbl>
## 1 Paraguay 2017 7000000
## 2 Uruguay 2017 3000000
## 3 Colombia 2017 45000000
## 4 Mexico 2017 127000000
## 5 Paraguay 2018 7200000
## 6 Uruguay 2018 3200000
## 7 Colombia 2018 46000000
## 8 Mexico 2018 128000000
Gather recibe como parámetros los nombres de las columnas que queremos pivotar, y los nombres de las dos columnas nuevas que queremos crear. ¡Mucho mejor! En el resultado final, las columnas problemáticas fueron eliminadas y nuestro dataset es ahora 100% ordenado.
Gather es útil cuando una fila corresponde a más de una observación. Spread funciona en el caso opuesto, cuando una única observación corresponde a múltiples filas. Considera la siguiente extensión de nuestro dataset de ejemplo:
world_count = tibble(
country = c("Paraguay", "Uruguay", "Colombia", "Mexico", "Paraguay", "Uruguay", "Colombia", "Mexico"),
year = 2018,
type = c("inhabitants", "inhabitants", "inhabitants", "inhabitants", "cars", "cars", "cars", "cars"),
count = c(7000000, 3000000, 45000000, 127000000, 1000000, 500000, 10000000, 75000000)
)
world_count
## # A tibble: 8 x 4
## country year type count
## <chr> <dbl> <chr> <dbl>
## 1 Paraguay 2018 inhabitants 7000000
## 2 Uruguay 2018 inhabitants 3000000
## 3 Colombia 2018 inhabitants 45000000
## 4 Mexico 2018 inhabitants 127000000
## 5 Paraguay 2018 cars 1000000
## 6 Uruguay 2018 cars 500000
## 7 Colombia 2018 cars 10000000
## 8 Mexico 2018 cars 75000000
En este caso tenemos variables almacenadas como valores de celdas, este caso se con inhabitants y cars. ¿Cómo lo arreglamos? Basta con dejar que spread haga su magia:
world_count %>% spread(key = type, value = count)
## # A tibble: 4 x 4
## country year cars inhabitants
## <chr> <dbl> <dbl> <dbl>
## 1 Colombia 2018 10000000 45000000
## 2 Mexico 2018 75000000 127000000
## 3 Paraguay 2018 1000000 7000000
## 4 Uruguay 2018 500000 3000000
Spread convierte cada valor en la columna dada por su parámetro key a una columna separada, completando los valores de la misma a partir de la columna dada por su parámetro value.
La última función de tidyr que visitaremos, separate, permite solucionar casos en los cuales valores múltiples están almacenados en la misma celda. Por ejemplo, considera el resultado de parsear el feed RSS de Uruguay que descargamos en la sección anterior.
index_tb
## # A tibble: 30,383 x 5
## title pubDate link guid category
## <chr> <chr> <chr> <chr> <chr>
## 1 id_compra:694189… Thu, 20 Dec… http://www.comprases… ajuste_a… awardUp…
## 2 id_compra:697126… Thu, 20 Dec… http://www.comprases… llamado-… tender
## 3 id_compra:688727… Thu, 20 Dec… http://www.comprases… adjudica… award
## 4 id_compra:697049… Thu, 20 Dec… http://www.comprases… adjudica… award
## 5 id_compra:697111… Thu, 20 Dec… http://www.comprases… adjudica… award
## 6 id_compra:697084… Thu, 20 Dec… http://www.comprases… adjudica… award
## 7 id_compra:697101… Thu, 20 Dec… http://www.comprases… aclar_ll… tenderU…
## 8 id_compra:695915… Thu, 20 Dec… http://www.comprases… aclar_ll… tenderU…
## 9 id_compra:696488… Thu, 20 Dec… http://www.comprases… aclar_ll… tenderU…
## 10 id_compra:696437… Thu, 20 Dec… http://www.comprases… aclar_ll… tenderU…
## # ... with 30,373 more rows
Está claro que la columna title contiene valores para dos variables: id_compra y release_id, los cuales están separados por una coma. Usemos separate para ordernar las cosas:
index_tb %>%
separate(title, into = c("id_compra", "release_id"), sep = ",") %>%
transform(id_compra=str_replace(id_compra,"id_compra:","")) %>%
transform(release_id=str_replace(release_id,"release_id:","")) %>%
# las últimas 2 líneas solo eliminan los prefijos innecesarios
head(5)
## id_compra release_id pubDate
## 1 694189 ajuste_adjudicacion-4221 Thu, 20 Dec 2018 11:05:23
## 2 697126 llamado-82830 Thu, 20 Dec 2018 11:05:19
## 3 688727 adjudicacion-82829 Thu, 20 Dec 2018 11:05:19
## 4 697049 adjudicacion-82828 Thu, 20 Dec 2018 11:05:18
## 5 697111 adjudicacion-82827 Thu, 20 Dec 2018 11:05:07
## link
## 1 http://www.comprasestatales.gub.uy/ocds/release/ajuste_adjudicacion-4221
## 2 http://www.comprasestatales.gub.uy/ocds/release/llamado-82830
## 3 http://www.comprasestatales.gub.uy/ocds/release/adjudicacion-82829
## 4 http://www.comprasestatales.gub.uy/ocds/release/adjudicacion-82828
## 5 http://www.comprasestatales.gub.uy/ocds/release/adjudicacion-82827
## guid category
## 1 ajuste_adjudicacion-4221 awardUpdate
## 2 llamado-82830 tender
## 3 adjudicacion-82829 award
## 4 adjudicacion-82828 award
## 5 adjudicacion-82827 award
Separate separa el valor de una columna usando sep como separador; los nombres de las nuevas columnas están dadas por el parámetro into, el cual debe ser una colección de cadenas. Las últimas dos líneas son un vistazo previo del paquete que cubriremos a continuación: dplyr.
Una vez que limpiamos nuestros datos con tidyr, los siguientes pasos del análisis de datos involucran la manipulación de los datos de distintas maneras. Seleccionar filas y columnas de acuerdo a ciertas condiciones, añadir columnas compuestas, sumarizar datos, etc. pueden citarse como ejemplos de las operaciones que se realizan frecuentemente como parte del proceso analítico. El Tidyverse incluye dplyr como la herramienta a utilizar para estas tareas, a continuación cubriremos algunas de sus funciones básicas.
La función mutate permite al usuario añadir una nueva columna a un tibble basado en los valores de una (o más) columnas ya existentes. Usemos el dataset de entregas de Uruguay para demostrar un caso en la cual está función puede ser de utilidad:
releases_tb
## # A tibble: 30,383 x 28
## ocid id date tag language initiationType parties awards
## <chr> <chr> <chr> <lis> <chr> <chr> <list> <list>
## 1 ocds… ajus… 2018… <chr… es tender <data.… <data…
## 2 ocds… llam… 2018… <chr… es tender <data.… <NULL>
## 3 ocds… adju… 2018… <chr… es tender <data.… <data…
## 4 ocds… adju… 2018… <chr… es tender <data.… <data…
## 5 ocds… adju… 2018… <chr… es tender <data.… <data…
## 6 ocds… adju… 2018… <chr… es tender <data.… <data…
## 7 ocds… acla… 2018… <chr… es tender <data.… <NULL>
## 8 ocds… acla… 2018… <chr… es tender <data.… <NULL>
## 9 ocds… acla… 2018… <chr… es tender <data.… <NULL>
## 10 ocds… acla… 2018… <chr… es tender <data.… <NULL>
## # ... with 30,373 more rows, and 20 more variables: buyer.name <chr>,
## # buyer.id <chr>, tender.id <chr>, tender.title <chr>,
## # tender.description <chr>, tender.status <chr>, tender.items <list>,
## # tender.procurementMethod <chr>, tender.procurementMethodDetails <chr>,
## # tender.submissionMethod <list>, tender.submissionMethodDetails <chr>,
## # tender.hasEnquiries <lgl>, tender.documents <list>,
## # tender.procuringEntity.name <chr>, tender.procuringEntity.id <chr>,
## # tender.tenderPeriod.startDate <chr>,
## # tender.tenderPeriod.endDate <chr>, tender.amendments <list>,
## # tender.enquiryPeriod.startDate <chr>,
## # tender.enquiryPeriod.endDate <chr>
Supongamos que necesitamos una columna con el nombre del mes en el cual se publicó una entrega. Este valor no está directamente disponible en el dataset, pero la información necesaria está contenida en la columna date y puede ser extraida con la ayuda de mutate:
uruguay_releases_with_month = releases_tb %>% mutate(month = month.name[month(date)])
## month.name es un vector con el nombre de cada mes
uruguay_releases_with_month[c('ocid', 'month')]
## # A tibble: 30,383 x 2
## ocid month
## <chr> <chr>
## 1 ocds-yfs5dr-694189 December
## 2 ocds-yfs5dr-697126 December
## 3 ocds-yfs5dr-688727 December
## 4 ocds-yfs5dr-697049 December
## 5 ocds-yfs5dr-697111 December
## 6 ocds-yfs5dr-697084 December
## 7 ocds-yfs5dr-697101 December
## 8 ocds-yfs5dr-695915 December
## 9 ocds-yfs5dr-696488 December
## 10 ocds-yfs5dr-696437 December
## # ... with 30,373 more rows
Mutate recibe un tibble como primer parámetro; recibe una expresión de asignación de variable como segundo parámetro, la cual se evalúa fila por fila de modo a producir la nueva columna.
En el fragmento de código anterior seleccionamos ciertas columnas de nuestro tibble usando la notación de índices de un arreglo. dplyr provee una función para hacer lo mismo, select:
select(uruguay_releases_with_month, ocid, month)
## # A tibble: 30,383 x 2
## ocid month
## <chr> <chr>
## 1 ocds-yfs5dr-694189 December
## 2 ocds-yfs5dr-697126 December
## 3 ocds-yfs5dr-688727 December
## 4 ocds-yfs5dr-697049 December
## 5 ocds-yfs5dr-697111 December
## 6 ocds-yfs5dr-697084 December
## 7 ocds-yfs5dr-697101 December
## 8 ocds-yfs5dr-695915 December
## 9 ocds-yfs5dr-696488 December
## 10 ocds-yfs5dr-696437 December
## # ... with 30,373 more rows
Select recibe un número variable de parámetros, el primero debe ser un tibble o data frame y el resto son los nombres de las columnas a incluir en la selección.
Una operación muy común al lidar con datos tabulares es seleccionar un subconjunto de filas de interés para el análisis. Por ejemplo, asumamos que solo estamos interesados en entregas relacionadas a adjudicaciones. La etapa del proceso de contratación a la que corresponde una entrega puede determinarse a partir de la columna tag.
releases_tb[1,]$tag
## [[1]]
## [1] "awardUpdate"
Sabiendo esto, es fácil obtener la colección de entregas que corrsponden a adjudicaciones usando filter como se muestra a continuación:
awards_tb = releases_tb %>% filter(tag == 'award')
awards_tb
## # A tibble: 14,784 x 28
## ocid id date tag language initiationType parties awards
## <chr> <chr> <chr> <lis> <chr> <chr> <list> <list>
## 1 ocds… adju… 2018… <chr… es tender <data.… <data…
## 2 ocds… adju… 2018… <chr… es tender <data.… <data…
## 3 ocds… adju… 2018… <chr… es tender <data.… <data…
## 4 ocds… adju… 2018… <chr… es tender <data.… <data…
## 5 ocds… adju… 2018… <chr… es tender <data.… <data…
## 6 ocds… adju… 2018… <chr… es tender <data.… <data…
## 7 ocds… adju… 2018… <chr… es tender <data.… <data…
## 8 ocds… adju… 2018… <chr… es tender <data.… <data…
## 9 ocds… adju… 2018… <chr… es tender <data.… <data…
## 10 ocds… adju… 2018… <chr… es tender <data.… <data…
## # ... with 14,774 more rows, and 20 more variables: buyer.name <chr>,
## # buyer.id <chr>, tender.id <chr>, tender.title <chr>,
## # tender.description <chr>, tender.status <chr>, tender.items <list>,
## # tender.procurementMethod <chr>, tender.procurementMethodDetails <chr>,
## # tender.submissionMethod <list>, tender.submissionMethodDetails <chr>,
## # tender.hasEnquiries <lgl>, tender.documents <list>,
## # tender.procuringEntity.name <chr>, tender.procuringEntity.id <chr>,
## # tender.tenderPeriod.startDate <chr>,
## # tender.tenderPeriod.endDate <chr>, tender.amendments <list>,
## # tender.enquiryPeriod.startDate <chr>,
## # tender.enquiryPeriod.endDate <chr>
awards_tb[1, ]$tag
## [[1]]
## [1] "award"
Filter recibe un tibble como primer parámetro y una expresión booleana como el segundo, la expresión booleana se evalúa para cada fila y solo las filas que resultan en TRUE se incluyen en el resultado final.
La sumarización nos permite agregar variables basados en un agrupamiento predefinido. Para ilustrar este concepto, revisitemos el dataset de Iris:
iris %>%
group_by(Name) %>%
summarize_if(is.numeric, mean)
## # A tibble: 3 x 5
## Name SepalLength SepalWidth PetalLength PetalWidth
## <fct> <dbl> <dbl> <dbl> <dbl>
## 1 Iris-setosa 5.01 3.42 1.46 0.244
## 2 Iris-versicolor 5.94 2.77 4.26 1.33
## 3 Iris-virginica 6.59 2.97 5.55 2.03
En el fragmento anterior, empezamos agrupando el dataset por el nombre de especie y luego sumarizamos cada columna numérica usando la media como función de agregación.
La visualización de datos puede definirse como el mapeo visual de datos usando atributos visuales como tamaño, forma y color para representar la variación de los valores de las variables en un dataset. Es una buena forma de comunicar información compleja, debido a que identificar patrones y hacer comparaciones es más fácil que con los datos en su formato original.
Esta sección introduce al lector a algunos conceptos básicos de visualización de datos, proveyendo algunos delineamientos básicos para la elección de una representación visual apropiada para un conjunto de datos. Aunque adquirir conocimiento teórico es definitivamente útil, las buenas noticias para el lector son que R tiende a guiar gentilmente hacia la elección correcta (si el usuario lo permite). La última frase será más clara una vez que cubramos ggplot2, la principal librería de visualización de R y un componente importante del Tidyverse.
Finalmente, pondremos a prueba lo aprendido graficando datos de OCDS usando ggplot2.
Cada vez que visualizamos datos, estamos codificando variables usando atributos visuales como tamaño, forma o color. Considera el ejemplo de una variable cuantitativa, la diferencia entre los valores asociados a cada observación pueden representarse de distintas maneras, como se muestra en la figura 2.
Como el lector habrá notado, muchos mapeos son posibles, sin embargo no todos ellos son igualmente apropiados. De hecho, los estadísticos William Cleveland and Robert McGill exploraron esta idea mediante experimentos con personas voluntarias buscando determinar que atributos visuales codificaban la información cuantitativa de forma más precisa. Sus resultados se resumen en la figura 3:
Aunque este orden de preferencia es una buena guía para variables cuantitativas, otros tipos de datos se mapean de forma diferente a los atributos visuales. Por ejemplo, mientras el color es una elección pobre para codificar una variable cuantitativa, funciona bien para codificar una variable categórica.
Diferentes combinaciones de elecciones de codificación resultan en diferentes diagramas. En este manual usaremos cuatro tipos de diagramas, los cuales se introducirán al utilizarse en la siguiente sección: el diagrama de barras, el diagrama de líneas, el diagrama de caja y el histograma. Existen muchos otros tipos de diagramas, y consideraciones adicionales que pueden tenerse en cuenta al visualizar datos; para una guía más detallada de estos temas se recomienda el curso de visualización de datos de Peter Aldhous, disponible en línea (las figuras de esta sección se tomaron de este curso).
ggplot2 es el paquete del Tidyverse para visualización de datos. Está basado en la gramática de los gráficos, una gramática formal para describir declarativamente la mayoría de los diagramas más comunes utilizados al visualizar datos.
Los diagramas se describen en ggplot usando un conjunto conciso de elementos los cuales pueden combinarse de acuerdo a una estructura básica definida por la gramática de los gráficos. Una versión simplificada de la gramática puede leerse a continuación:
ggplot(data = [DATA]) +
[GEOM_FUNCTION](mapping = aes([MAPPINGS]))
Puedes considerar el anterior fragmento como una plantilla para gráficos. Para hacer un diagrama, simplemente rellena los elementos entre corchetes con valores reales:
Usemos esta plantilla para graficar el dataset de Iris con un gráfico de dispersión:
ggplot(data = iris) + geom_point(mapping = aes(x = PetalLength, y = PetalWidth, color = Name))
Como el lector habrá notado, ggplot2 favorece la convención frente a la configuración y cualquier diagrama que grafiquemos incluye muchas buenas prácticas por defecto. Por ejemplo, en el diagrama de dispersión anterior obtuvimos etiquetas en los ejes y una leyenda por defecto especificando solo los mapeos mediante aesthetics.
Existen componentes adicionales de la gramática de ggplot2, pero para esta breve introducción nuestra plantilla simplificada es suficiente. Para más información acerca de los elementos disponibles, además de una lista exhaustiva de geoms y aesthetics favor visitar el sitio oficial de la librería.
Ahora que conocemos lo básico de ggplot2 podemos empezar a dibujar unos cuantos diagramas, o casi. Como nuestro mayor interés es comparar montos por año y comprador, debemos empezar por extraer todas las filas de nuestro dataset que contienen al menos un contrato. Logramos esto construyendo un índice booleano con un elemento por fila de nuestro dataset, indicando si la fila correspondiente pasa la condición o no.
contract_indices <- parApply(cl, dncp_records_tb, 1, function(r) {
contracts <- r['compiledRelease.contracts'][[1]]
return(!is.null(contracts) && !is.null(dim(contracts)))
})
Una vez construido nuestro índice booleano, podemos usarlo para filtrar los registros en los cuales no estamos interesados, manteniendo solo aquellos de utilidad para nuestro análisis; conseguimos esto ejecutando dncp_records_tb[contract_indices, ]
en el siguiente fragmento de código. Luego, procedemos a extraer los contratos y el nombre del comprador de los registros de interés.
contracts <- parApply(cl, dncp_records_tb[contract_indices, ], 1, function(r) {
result <- r['compiledRelease.contracts'][[1]]
result['buyer.name'] <- r['compiledRelease.buyer.name']
result['publishedDate'] <- r['publishedDate']
return(result)
})
El objeto contracts
definido arriba es una colección anidada de contratos. Para convertirlo en un único tibble llamamos a la función bind_rows
. Liberamos la memoria que ya no usamos explícitamente retirando el objeto contracts
de memoria.
contracts_dncp <- bind_rows(contracts)
rm(contracts)
Disponemos actualmente de un dataset de contratos publicados por la DNCP listo para el análisis y visualización. Podemos seguir pasos similares para obtener uno a partir del dataset del Ministerio de Hacienda.
contracts <- parApply(cl, mh_releases_tb, 1, function(r) {
result <- r['contracts'][[1]]
result['buyer.name'] <- r['buyer.name']
result['publishedDate'] <- r['publishedDate']
return(result)
})
contracts_hacienda <- bind_rows(contracts)
rm(contracts)
Sabemos que existe un solapamiento significativo entre las publicaciones de la DNCP y el Ministerio de Haciendo, de modo que unir los datasets de forma ingenua llevará a la duplicación de contratos haciendo inválido nuestro análisis. Primeramente, asegurémonos de que el solapamiento existe, seleccionando los contratos que están presentes en ambos datasets. La función subset
selecciona filas de un tibble basada en una condición; en este caso, seleccionamos la intersección de ambos datasets por lo cual un resultado no vacío confirmaría la existencia de duplicados.
overlap <- subset(
contracts_dncp,
(dncpContractCode %in% contracts_hacienda[['dncpContractCode']]))['dncpContractCode']
dim(overlap)
## [1] 43351 1
Habiendo confirmado la presencia de contratos duplicados, necesitamos encontrar una estrategia más inteligente para fusionar los datasets: podemos mezclar ambos tibbles y agruparlos de acuerdo al dncpContractCode (lo cual debería agrupar a duplicados juntos), ordenar las filas dentro de cada grupo por publishedDate de forma ascendente, y finalmente seleccionar la última fila de cada grupo. Este paso de preprocesamiento, implementado en el siguiente fragmento de código, asegura que incluimos únicamente la versión más actualizada de cada contrato en nuestro análisis.
contracts_paraguay <- bind_rows(contracts_dncp, contracts_hacienda) %>%
group_by(dncpContractCode) %>%
arrange(publishedDate) %>%
slice(n()) %>%
ungroup
contracts_paraguay
## # A tibble: 147,196 x 24
## awardID title dncpContractCode lots documents dateSigned id items
## <chr> <chr> <chr> <lis> <list> <chr> <chr> <lis>
## 1 338222… Cont… _NO_APLICA_ <NUL… <data.fr… <NA> 3382… <NUL…
## 2 195388… Cont… AC-11001-10-0155 <NUL… <data.fr… 2010-09-2… 1953… <NUL…
## 3 156467… Cont… AC-11001-10-0290 <NUL… <data.fr… 2010-10-2… 1564… <NUL…
## 4 156467… Cont… AC-11001-10-0292 <NUL… <data.fr… 2010-10-2… 1564… <NUL…
## 5 156467… Cont… AC-11001-10-0293 <NUL… <data.fr… 2010-10-2… 1564… <NUL…
## 6 185054… Cont… AC-11001-10-0319 <NUL… <data.fr… 2010-10-2… 1850… <NUL…
## 7 185054… Cont… AC-11001-10-0361 <NUL… <data.fr… 2010-11-0… 1850… <NUL…
## 8 185054… Cont… AC-11001-10-0362 <NUL… <data.fr… 2010-11-0… 1850… <NUL…
## 9 201352… Cont… AC-11001-10-0363 <NUL… <data.fr… 2010-11-0… 2013… <NUL…
## 10 186382… Cont… AC-11001-10-0408 <NUL… <data.fr… 2010-11-0… 1863… <NUL…
## # ... with 147,186 more rows, and 16 more variables: url <chr>,
## # status <chr>, value.amount <dbl>, value.currency <chr>,
## # suppliers.name <chr>, suppliers.identifier.scheme <chr>,
## # suppliers.identifier.legalName <chr>, suppliers.identifier.id <chr>,
## # suppliers.identifiers.key <chr>, period.endDate <chr>,
## # period.startDate <chr>, buyer.name <chr>, publishedDate <dbl>,
## # extendsContractID <chr>, dncpAmendmentType <chr>,
## # implementation.transactions <list>
Seguido, está el problema de la moneda. Examinemos que monedas fueron utilizadas y cuantos contratos se firmaron con cada una.
contracts_paraguay %>%
group_by(value.currency) %>%
summarise(count = n())
## # A tibble: 3 x 2
## value.currency count
## <chr> <int>
## 1 PYG 146700
## 2 USD 495
## 3 <NA> 1
Los resultados presentados en la anterior tabla muestran que la amplia mayoría de los contratos fueron firmados con montos en Guaraníes (PYG), un hecho poco sorprendente considerando que estamos analizando contratos del Paraguay. Podríamos incluir los contratos en dólares americanos multiplicando el monto por una tasa de cambio determinada, pero por simplicidad consideraremos solo los contratos en moneda local.
Continuando con nuestro análisis, obtengamos un tibble de nuestro dataset de contratos agrupados por año y algunas sumarizaciones interesantes. Primero, extraemos el año del campo dateSigned usando la función mutate
, luego filtramos las filas con valores inválidos (NA) y seleccionamos solo los contratos en PYG usando la función filter
. Finalmente, agrupamos los contratos restantes por año usando la función group_by
y obtenemos el conteo y la suma de montos por grupo usando la función summarise
.
by_year <- contracts_paraguay %>%
mutate(signed_year = year(dateSigned)) %>%
filter(!is.na(signed_year), !is.na(value.amount),
signed_year < 2019, signed_year > 2009, value.currency == 'PYG') %>%
group_by(signed_year) %>%
summarise(count = n(), amount = sum(value.amount))
Con el dataset definido previamente, podemos dibujar gráficos de líneas con unas pocas líneas de magia de ggplot2. Grafiquemos el número de contratos y el monto total de todos los contratos por año:
ggplot(data=by_year, aes(x=signed_year, y=count)) +
geom_line(color="blue") +
geom_point() +
labs(x = 'Anho', y = '# de contratos')
ggplot(data=by_year, aes(x=signed_year, y=amount)) +
geom_line(color='springgreen4') +
geom_point() +
labs(x = 'Anho', y = 'Monto total (PYG)')
Ya dibujamos unos cuantos gráficos de líneas, ¿qué hay de las barras? Grafiquemos el número de contratos por comprador para un año determinado (2017). El lector debería empezar a notar un patrón: empezamos por dejar nuestros datos en el formato correcto usando verbos dplyr y solo entonces procedemos a la visualización. Los gráficos de barras requieren un paso adicional, al menos si queremos darles la típica apariencia de ordenamiento por longitud: los nombres de compradores son arreglos de caracteres y por tanto se ordenan lexicográficamente por defecto, necesitamos redefinir el orden de las columnas usando el tipo de datos factor (usado para variables categóricas) y configurar un orden personalizado usando la función order
.
count_by_buyer <- contracts_paraguay %>%
mutate(signed_year = year(dateSigned)) %>%
filter(value.currency == 'PYG', signed_year == 2017) %>%
group_by(buyer.name) %>%
summarise(ccount = n()) %>%
arrange(desc(ccount)) %>%
head(5)
count_by_buyer$buyer.name <- factor(
count_by_buyer$buyer.name,
levels = count_by_buyer$buyer.name[order(count_by_buyer$ccount)])
ggplot(data=count_by_buyer, aes(x=buyer.name, y=ccount)) +
geom_bar(stat="identity", fill="steelblue") +
coord_flip() +
labs(y = '# de contratos', x = 'Comprador')
Podemos seguir pasos similares para dibujar el gráfico de barras considerando el monto total por comprador:
amount_by_buyer <- contracts_paraguay %>%
mutate(signed_year = year(dateSigned)) %>%
filter(value.currency == 'PYG', signed_year == 2017) %>%
group_by(buyer.name) %>%
summarise(amount = sum(value.amount)) %>%
arrange(desc(amount)) %>%
head(5)
top_buyers = amount_by_buyer$buyer.name
amount_by_buyer$buyer.name <- factor(
amount_by_buyer$buyer.name,
levels = amount_by_buyer$buyer.name[order(amount_by_buyer$amount)])
ggplot(data=amount_by_buyer, aes(x=buyer.name, y=amount)) +
geom_bar(stat="identity", fill="springgreen4") +
coord_flip() +
labs(y = 'Monto total (PYG)', x = 'Comprador')
Los gráficos que dibujamos hasta el momento son útiles para mostrar sumarizaciones agrupadas, pero no nos dan una pista acerca de la distribución subyacente de la variable numérica. Los gráficos de caja sirven para visualizar distribuciones agrupadas por una variable categórica, junto a unas cuantas estadísticas útiles; la figura 4, tomada de un curso de estadísticas de la BBC muestra como interpretar cada componente de un diagrama de caja.
La belleza de ggplot2 yace en su naturaleza declarativa. Examina el siguiente fragmento de código, que permite graficar un diagrama de caja: la única diferencia con el ejemplo anterior es el uso de un geom diferente (geom_boxplot
).
contracts_top5_buyers <- contracts_paraguay %>%
mutate(signed_year = year(dateSigned)) %>%
filter(value.currency == 'PYG', signed_year == 2017, buyer.name %in% top_buyers) %>%
arrange(desc(value.amount)) %>%
tail(1000)
contracts_top5_buyers$buyer.name <- factor(contracts_top5_buyers$buyer.name)
ggplot(data=contracts_top5_buyers, aes(x=buyer.name, y=value.amount, color=buyer.name)) +
geom_boxplot(outlier.colour="black", outlier.shape=NA, notch=FALSE) +
labs(y = 'Monto total (PYG)', x = 'Comprador') +
theme(axis.text.y=element_blank()) +
coord_flip()
El párrafo anterior contenía una pequeña mentira, ya que existe otra diferencia en el fragmento de código anterior: como existe un comprador con contratos de montos mucho más grandes que los demás, nuestro gráfico de cajas se degenera al graficar todos los contratos. Para evitar esto, graficamos solo la distribución de los 1000 contratos con menores montos.
Un histograma es la elección apropiada si queremos representar la distribución de una variable cuantitativa sin ningún agrupamiento. Un histograma divide el dominio completo de la variable en múltiples intervalos de igual tamaño (llamados normalmente cubos) y cuenta la cantidad de observaciones (contratos en este caso) que caen en cada cubo. El siguiente fragemento de código dibujo un histograma de los montos de contratos del 2017; nuevamente, para filtrar valores extremos y obtener un gráfico significativo considermos solo los últimos 10.000 contratos.
selected_contracts <- contracts_paraguay %>%
mutate(signed_year = year(dateSigned)) %>%
filter(value.currency == 'PYG', signed_year == 2017, !is.na(value.amount)) %>%
arrange(desc(value.amount)) %>%
tail(10000)
ggplot(data=selected_contracts, aes(value.amount)) +
geom_histogram(fill='steelblue', col='black') +
labs(x = 'Monto (PYG)', y = '# de contratos')
En los últimos dos ejemplos hemos restringido nuestro análisis a un cierto extremo de la distribución. Esto no es poco frecuente, en particular en presencia de valores extremos, ya que la diferencia de magnitudes dificulta obtener conclusiones de un gráfico general. Es fácil cambiar la parte de la distribución que examinamos cambiando el parámetro de la función tail
o usando su función complementaria head
. Si estás corriendo este notebook en modo interacitvo puedes tomarte unos minutos para hacerlo e intentar encontar patrones interesantes en los datos.
Como nuestro último diagrama dibujaremos un histograma para los contratos mexicanos, los cuales se leerán de una base de datos Postgres usando la función de streaming implementada en secciones anteriores. Esto se logra definiendo un callback que extraiga los montos de los contratos para cada contrato de cada compiledRelease publicado. Resumiendo, el callback que definimos en el siguiente fragmento de código:
dim
).amount_getter <- function(data, acc) {
m <- parLapply(cl, data, function(e) {
t <- as_tibble(fromJSON(e, flatten = TRUE)$compiledRelease$contracts)
if (dim(t)[[1]] > 0) {
if (!("valueWithTax.amount" %in% colnames(t))) {
t$valueWithTax.amount = -1
t$valueWithTax.currency = "BTC"
}
return(select(t, valueWithTax.amount, valueWithTax.currency))
} else {
return(NULL)
}
})
if (is.tibble(acc)) {
return(bind_rows(acc, m))
} else {
return(bind_rows(m))
}
}
contracts <- stream_in_mexico(amount_getter)
Vale la pena destacar lo incómodo y complicado de este procesamiento á-la-map-reduce, dejando de lado su eficiencia, al compararlo con las funciones dplyr usadas en ejemplos anteriores. La moraleja de la historia es: aunque R puede manejar cómputo de memoria externa, varias de sus herramientas no están preparadas de la mejor manera para estos casos. Habiendo dicho esto, una vez que tenemos un tibble que sí entra en memoria, las cosas se vuelven amigables de vuelta como puede verse en los siguientes fragmentos de código:
contracts_mx <- contracts %>%
filter(valueWithTax.currency == 'MXN') %>%
arrange(desc(valueWithTax.amount)) %>%
tail(10000)
ggplot(data=contracts_mx, aes(valueWithTax.amount)) +
geom_histogram(fill='forestgreen', col='black') +
labs(x = 'Monto (MXN)', y = '# de contratos')
stopCluster(cl)
Como terminamos con la mayor parte del procesamiento pesado, no debemos olvidar limpiar nuestro propio desorden. La función stopCluster
libera todos los recursos utilizados por nuestros nodos de procesamiento paralelo.
En las páginas de esta guía hemos presentado al lector el estándar de datos Open Contracting y lenguaje de programación R; además, hemos mostrado como usar R, y más específicamente las herramientas disponibles como parte del Tidyverse, para llevar a cabo la lectura, limpieza, análisis y visualización de datos.
Aspectos específicos del formato de los datos y el volumen de los datasets disponibles han sido razón suficiente para introducir conceptos más avanzados como el procesamiento paralelo. Habiendo dicho esto, los publicadores pueden mejorar la experiencia de los analistas de datos:
Habiendo alcanzado este punto, el lector debería ser capaz de llevar a cabo análisis de su propia autoría utilizando datasets del OCDS y el lenguaje de programación R. A través de esta guía, hemos proveido diferentes enlaces que apuntan a recursos para continuar aprendiendo acerca del lenguaje y su ecosistema de herramientas. Ahora es momento de aplicar lo aprendido para la persecución de la meta del OCDS: incrementar la transparencia y permitir análisis más profundo de datos de contrataciones públicas.
El autor recomienda RStudio como la manera más conveniente de ejecutar código R y cree que este IDE es una de las principales razones tras la popularidad de R. En consecuencia, esta guía fue escrita originalmente usando RStudio v1.1.453 en macOS High Sierra.↩
Este notebook fue escrito intentando respetar la guía de estilo del Tidyverse. Cualquier fragmento de código que no lo siga puede atribuirse a la falta de buen café en ese punto del proceso de redacción :)↩