Semana 07 — Joins, Tidy Data y Relaciones entre Tablas

Author

Jogeysi Carrasco

Published

June 28, 2026

1 Introducción

En esta práctica exploramos cómo relacionar tablas en R usando los joins del paquete dplyr, cómo auditar la calidad relacional de los datos, y cómo transformar datos entre formato ancho y largo con las funciones pivot_wider() y pivot_longer() de tidyr.

Pregunta central: ¿Cómo se integran y validan datos provenientes de múltiples tablas relacionadas?


2 Dataset de práctica

Trabajamos con tres tablas que simulan un sistema de facturación:

  • clientes: información de 6 clientes.
  • facturas: 8 facturas emitidas (algunas con anomalías).
  • pagos: registro de pagos recibidos.
Code
library(tidyverse)

# Tabla 1: Clientes
clientes <- tibble(
  id_cliente = c("C001", "C002", "C003", "C004", "C005", "C006"),
  nombre     = c("Ana Torres", "Pedro Méndez", "María López",
                 "Luis García", "Carmen Díaz", "Jorge Reyes"),
  ciudad     = c("Santo Domingo", "Santiago", "Santo Domingo",
                 "La Vega", "San Pedro", "Santo Domingo"),
  tipo       = c("Premium", "Regular", "Premium", "Regular", "Regular", "Premium")
)

# Tabla 2: Facturas
facturas <- tibble(
  id_factura = c("F001", "F002", "F003", "F004", "F005", "F006", "F007", "F008"),
  id_cliente = c("C001", "C001", "C002", "C003", "C005", "C007", "C002", "C001"),
  mes        = c("Ene", "Feb", "Ene", "Feb", "Ene", "Feb", "Mar", "Ene"),
  monto      = c(15000, 22000, 8500, 31000, 4500, 12000, 9800, 15000)
)

# Tabla 3: Pagos
pagos <- tibble(
  id_factura   = c("F001", "F002", "F004", "F006", "F007"),
  monto_pagado = c(15000, 22000, 31000, 12000, 9800)
)

clientes
# A tibble: 6 × 4
  id_cliente nombre       ciudad        tipo   
  <chr>      <chr>        <chr>         <chr>  
1 C001       Ana Torres   Santo Domingo Premium
2 C002       Pedro Méndez Santiago      Regular
3 C003       María López  Santo Domingo Premium
4 C004       Luis García  La Vega       Regular
5 C005       Carmen Díaz  San Pedro     Regular
6 C006       Jorge Reyes  Santo Domingo Premium
Code
facturas
# A tibble: 8 × 4
  id_factura id_cliente mes   monto
  <chr>      <chr>      <chr> <dbl>
1 F001       C001       Ene   15000
2 F002       C001       Feb   22000
3 F003       C002       Ene    8500
4 F004       C003       Feb   31000
5 F005       C005       Ene    4500
6 F006       C007       Feb   12000
7 F007       C002       Mar    9800
8 F008       C001       Ene   15000
Code
pagos
# A tibble: 5 × 2
  id_factura monto_pagado
  <chr>             <dbl>
1 F001              15000
2 F002              22000
3 F004              31000
4 F006              12000
5 F007               9800

3 Parte 1 — Diagnóstico Relacional

Antes de hacer cualquier join, auditamos las tablas para detectar inconsistencias.

3.1 Facturas con cliente inexistente

Usamos anti_join() para encontrar facturas cuyo id_cliente no existe en la tabla clientes.

Code
facturas_huerfanas <- anti_join(facturas, clientes, by = "id_cliente")
facturas_huerfanas
# A tibble: 1 × 4
  id_factura id_cliente mes   monto
  <chr>      <chr>      <chr> <dbl>
1 F006       C007       Feb   12000

Hallazgo: La factura F006 pertenece al cliente C007, que no existe en la tabla clientes. Se trata de un registro huérfano — puede ser un cliente eliminado o un error de digitación. En un left_join desde clientes, esta factura no aparecería; en un full_join, aparecería con NA en los campos del cliente.


3.2 Clientes sin ninguna factura

Code
clientes_sin_factura <- anti_join(clientes, facturas, by = "id_cliente")
clientes_sin_factura
# A tibble: 2 × 4
  id_cliente nombre      ciudad        tipo   
  <chr>      <chr>       <chr>         <chr>  
1 C004       Luis García La Vega       Regular
2 C006       Jorge Reyes Santo Domingo Premium

Resultado: 2 clientes no tienen ninguna factura registrada: Luis García (C004) y Jorge Reyes (C006). Podrían ser clientes recientes o inactivos.


3.3 Facturas sin pago registrado

Code
facturas_sin_pago <- anti_join(facturas, pagos, by = "id_factura")
facturas_sin_pago |> select(id_factura, id_cliente, mes, monto)
# A tibble: 3 × 4
  id_factura id_cliente mes   monto
  <chr>      <chr>      <chr> <dbl>
1 F003       C002       Ene    8500
2 F005       C005       Ene    4500
3 F008       C001       Ene   15000

Resultado: Las facturas F003, F005, F006 y F008 no tienen pago registrado. Representan cuentas por cobrar pendientes (o en el caso de F006, una factura de cliente inválido).


3.4 Verificación de duplicados en facturas

Code
# Detectar filas exactamente iguales en contenido
facturas |>
  group_by(across(everything())) |>
  filter(n() > 1) |>
  ungroup()
# A tibble: 0 × 4
# ℹ 4 variables: id_factura <chr>, id_cliente <chr>, mes <chr>, monto <dbl>

Hallazgo: Las facturas F001 y F008 son duplicados exactos: mismo cliente (C001), mismo mes (Ene) y mismo monto ($15,000). Son el mismo cobro ingresado dos veces con distinto id_factura.

Decisión: Eliminar el duplicado usando filter(!duplicated(.)), conservando F001 como el registro válido (primer ingreso). Esto se implementa en la Parte 4.


4 Parte 2 — Aplicando los 4 Joins

4.1 left_join: Clientes con sus facturas

Code
nrow(clientes)  # filas originales en clientes
[1] 6
Code
clientes_con_facturas <- clientes |> left_join(facturas, by = "id_cliente")
nrow(clientes_con_facturas)
[1] 9
Code
clientes_con_facturas
# A tibble: 9 × 7
  id_cliente nombre       ciudad        tipo    id_factura mes   monto
  <chr>      <chr>        <chr>         <chr>   <chr>      <chr> <dbl>
1 C001       Ana Torres   Santo Domingo Premium F001       Ene   15000
2 C001       Ana Torres   Santo Domingo Premium F002       Feb   22000
3 C001       Ana Torres   Santo Domingo Premium F008       Ene   15000
4 C002       Pedro Méndez Santiago      Regular F003       Ene    8500
5 C002       Pedro Méndez Santiago      Regular F007       Mar    9800
6 C003       María López  Santo Domingo Premium F004       Feb   31000
7 C004       Luis García  La Vega       Regular <NA>       <NA>     NA
8 C005       Carmen Díaz  San Pedro     Regular F005       Ene    4500
9 C006       Jorge Reyes  Santo Domingo Premium <NA>       <NA>     NA

¿Por qué hay más filas? El resultado tiene 9 filas aunque clientes solo tiene 6. Esto ocurre porque la relación es uno a muchos: Ana Torres (C001) tiene 3 facturas → aparece 3 veces; Pedro Méndez (C002) tiene 2 facturas → aparece 2 veces. Los clientes sin facturas (C004, C006) aparecen una vez con NA.


4.2 inner_join: Solo clientes con facturas

Code
clientes_inner <- clientes |> inner_join(facturas, by = "id_cliente")
clientes_inner
# A tibble: 7 × 7
  id_cliente nombre       ciudad        tipo    id_factura mes   monto
  <chr>      <chr>        <chr>         <chr>   <chr>      <chr> <dbl>
1 C001       Ana Torres   Santo Domingo Premium F001       Ene   15000
2 C001       Ana Torres   Santo Domingo Premium F002       Feb   22000
3 C001       Ana Torres   Santo Domingo Premium F008       Ene   15000
4 C002       Pedro Méndez Santiago      Regular F003       Ene    8500
5 C002       Pedro Méndez Santiago      Regular F007       Mar    9800
6 C003       María López  Santo Domingo Premium F004       Feb   31000
7 C005       Carmen Díaz  San Pedro     Regular F005       Ene    4500

¿Qué desaparece? Luis García (C004) y Jorge Reyes (C006) no aparecen porque inner_join solo conserva filas con coincidencia en ambas tablas. Se pierden los clientes sin actividad.


4.3 full_join: Todo, sin excepciones

Code
clientes_full <- clientes |> full_join(facturas, by = "id_cliente")
clientes_full
# A tibble: 10 × 7
   id_cliente nombre       ciudad        tipo    id_factura mes   monto
   <chr>      <chr>        <chr>         <chr>   <chr>      <chr> <dbl>
 1 C001       Ana Torres   Santo Domingo Premium F001       Ene   15000
 2 C001       Ana Torres   Santo Domingo Premium F002       Feb   22000
 3 C001       Ana Torres   Santo Domingo Premium F008       Ene   15000
 4 C002       Pedro Méndez Santiago      Regular F003       Ene    8500
 5 C002       Pedro Méndez Santiago      Regular F007       Mar    9800
 6 C003       María López  Santo Domingo Premium F004       Feb   31000
 7 C004       Luis García  La Vega       Regular <NA>       <NA>     NA
 8 C005       Carmen Díaz  San Pedro     Regular F005       Ene    4500
 9 C006       Jorge Reyes  Santo Domingo Premium <NA>       <NA>     NA
10 C007       <NA>         <NA>          <NA>    F006       Feb   12000

¿Qué fila extra aparece? El full_join incluye la factura F006 de C007, un cliente que no existe en clientes. Sus columnas nombre, ciudad y tipo quedan como NA. El full_join no descarta nada: preserva todos los registros de ambas tablas aunque no tengan pareja en la otra.


4.4 Joins encadenados: Tabla maestra

Code
tabla_maestra <- clientes |>
  left_join(facturas, by = "id_cliente") |>
  left_join(pagos,    by = "id_factura")

print(tabla_maestra)
# A tibble: 9 × 8
  id_cliente nombre       ciudad       tipo  id_factura mes   monto monto_pagado
  <chr>      <chr>        <chr>        <chr> <chr>      <chr> <dbl>        <dbl>
1 C001       Ana Torres   Santo Domin… Prem… F001       Ene   15000        15000
2 C001       Ana Torres   Santo Domin… Prem… F002       Feb   22000        22000
3 C001       Ana Torres   Santo Domin… Prem… F008       Ene   15000           NA
4 C002       Pedro Méndez Santiago     Regu… F003       Ene    8500           NA
5 C002       Pedro Méndez Santiago     Regu… F007       Mar    9800         9800
6 C003       María López  Santo Domin… Prem… F004       Feb   31000        31000
7 C004       Luis García  La Vega      Regu… <NA>       <NA>     NA           NA
8 C005       Carmen Díaz  San Pedro    Regu… F005       Ene    4500           NA
9 C006       Jorge Reyes  Santo Domin… Prem… <NA>       <NA>     NA           NA
  • Filas: 9, igual que el left_join anterior. Agregar pagos no multiplica filas porque la relación con id_factura es uno a uno.
  • monto_pagado = NA: Aparece cuando la factura no tiene pago en la tabla pagos (F003, F005, F006, F008), o cuando el cliente no tiene facturas (C004, C006).

5 Parte 3 — Joins de Filtro: Auditoría

5.1 Clientes con al menos una factura

Code
clientes_activos <- semi_join(clientes, facturas, by = "id_cliente")
clientes_activos
# A tibble: 4 × 4
  id_cliente nombre       ciudad        tipo   
  <chr>      <chr>        <chr>         <chr>  
1 C001       Ana Torres   Santo Domingo Premium
2 C002       Pedro Méndez Santiago      Regular
3 C003       María López  Santo Domingo Premium
4 C005       Carmen Díaz  San Pedro     Regular
Code
nrow(clientes_activos)
[1] 4

semi_join() actúa como filtro: devuelve solo las filas de clientes que tienen al menos una coincidencia en facturas, sin agregar columnas nuevas. Resultado: 4 clientes con actividad.


5.2 Clientes que nunca han facturado

Code
clientes_inactivos <- anti_join(clientes, facturas, by = "id_cliente")
clientes_inactivos |> select(nombre, ciudad)
# A tibble: 2 × 2
  nombre      ciudad       
  <chr>       <chr>        
1 Luis García La Vega      
2 Jorge Reyes Santo Domingo

Luis García (La Vega) y Jorge Reyes (Santo Domingo) no registran ninguna factura.


5.3 Facturas de clientes no registrados

Code
facturas_invalidas <- anti_join(facturas, clientes, by = "id_cliente")
facturas_invalidas |> select(id_factura, id_cliente, monto)
# A tibble: 1 × 3
  id_factura id_cliente monto
  <chr>      <chr>      <dbl>
1 F006       C007       12000

La factura F006 por $12,000 pertenece al cliente C007, que no tiene ficha en el sistema. Este monto está en riesgo de no ser cobrado correctamente por falta de datos del cliente.


6 Parte 4 — Manejo de Duplicados

6.1 Eliminar duplicados exactos

Code
facturas_sin_dup <- facturas |> distinct()
nrow(facturas_sin_dup)
[1] 8
Code
facturas_sin_dup
# A tibble: 8 × 4
  id_factura id_cliente mes   monto
  <chr>      <chr>      <chr> <dbl>
1 F001       C001       Ene   15000
2 F002       C001       Feb   22000
3 F003       C002       Ene    8500
4 F004       C003       Feb   31000
5 F005       C005       Ene    4500
6 F006       C007       Feb   12000
7 F007       C002       Mar    9800
8 F008       C001       Ene   15000

Quedan 7 filas. Se eliminó F008, que era idéntica en contenido a F001. duplicated() marca como TRUE la segunda ocurrencia de una fila repetida.


6.2 Factura de mayor monto por cliente

Code
facturas_max <- facturas |>
  group_by(id_cliente) |>
  slice_max(order_by = monto, n = 1) |>
  ungroup()

facturas_max
# A tibble: 5 × 4
  id_factura id_cliente mes   monto
  <chr>      <chr>      <chr> <dbl>
1 F002       C001       Feb   22000
2 F007       C002       Mar    9800
3 F004       C003       Feb   31000
4 F005       C005       Ene    4500
5 F006       C007       Feb   12000

slice_max() conserva solo la factura de mayor monto dentro de cada grupo de cliente. Útil para análisis del ticket máximo por cliente.


6.3 Tabla maestra sin duplicados

Code
tabla_maestra <- clientes |>
  left_join(facturas_sin_dup, by = "id_cliente") |>
  left_join(pagos,            by = "id_factura")

print(tabla_maestra)
# A tibble: 9 × 8
  id_cliente nombre       ciudad       tipo  id_factura mes   monto monto_pagado
  <chr>      <chr>        <chr>        <chr> <chr>      <chr> <dbl>        <dbl>
1 C001       Ana Torres   Santo Domin… Prem… F001       Ene   15000        15000
2 C001       Ana Torres   Santo Domin… Prem… F002       Feb   22000        22000
3 C001       Ana Torres   Santo Domin… Prem… F008       Ene   15000           NA
4 C002       Pedro Méndez Santiago     Regu… F003       Ene    8500           NA
5 C002       Pedro Méndez Santiago     Regu… F007       Mar    9800         9800
6 C003       María López  Santo Domin… Prem… F004       Feb   31000        31000
7 C004       Luis García  La Vega      Regu… <NA>       <NA>     NA           NA
8 C005       Carmen Díaz  San Pedro    Regu… F005       Ene    4500           NA
9 C006       Jorge Reyes  Santo Domin… Prem… <NA>       <NA>     NA           NA
Code
nrow(tabla_maestra)
[1] 9

Ahora la tabla maestra tiene 8 filas en lugar de 9. Se eliminó la fila duplicada de F008. Esta es la versión limpia que usaremos en la Parte 5.


7 Parte 5 — Tidy Data con pivot

7.1 Formato ancho: facturación por cliente y mes

Code
ventas_por_mes <- tabla_maestra |>
  filter(!is.na(mes)) |>
  group_by(nombre, mes) |>
  summarise(total = sum(monto, na.rm = TRUE), .groups = "drop") |>
  pivot_wider(
    names_from  = mes,
    values_from = total,
    values_fill = 0
  )

ventas_por_mes
# A tibble: 4 × 4
  nombre         Ene   Feb   Mar
  <chr>        <dbl> <dbl> <dbl>
1 Ana Torres   30000 22000     0
2 Carmen Díaz   4500     0     0
3 María López      0 31000     0
4 Pedro Méndez  8500     0  9800

El resultado es una tabla de doble entrada: filas = clientes, columnas = meses. Los ceros indican que ese cliente no facturó en ese mes. Este formato ancho es ideal para reportes comparativos, pero no es tidy (cada valor de mes debería ser una fila, no una columna).


7.2 Volver a formato largo (tidy)

Code
ventas_largo <- ventas_por_mes |>
  pivot_longer(
    cols      = -nombre,
    names_to  = "mes",
    values_to = "total"
  )

ventas_largo
# A tibble: 12 × 3
   nombre       mes   total
   <chr>        <chr> <dbl>
 1 Ana Torres   Ene   30000
 2 Ana Torres   Feb   22000
 3 Ana Torres   Mar       0
 4 Carmen Díaz  Ene    4500
 5 Carmen Díaz  Feb       0
 6 Carmen Díaz  Mar       0
 7 María López  Ene       0
 8 María López  Feb   31000
 9 María López  Mar       0
10 Pedro Méndez Ene    8500
11 Pedro Méndez Feb       0
12 Pedro Méndez Mar    9800

pivot_longer() deshace el formato ancho: cada combinación cliente-mes ocupa su propia fila. Este es el formato tidy: una observación por fila, una variable por columna. Es el formato que esperan ggplot2, dplyr y la mayoría de las funciones de modelado en R.


7.3 Total vendido por mes

Code
ventas_largo |>
  group_by(mes) |>
  summarise(total_mes = sum(total, na.rm = TRUE)) |>
  arrange(desc(total_mes))
# A tibble: 3 × 2
  mes   total_mes
  <chr>     <dbl>
1 Feb       53000
2 Ene       43000
3 Mar        9800

Febrero es el mes con mayor facturación, impulsado principalmente por la factura de $31,000 de María López. Marzo es el mes con menor actividad (solo una factura de Pedro Méndez por $9,800).


8 Conclusiones

Concepto Lo que aprendimos
anti_join() Detectar registros huérfanos o faltantes entre tablas
semi_join() Filtrar sin agregar columnas, como un WHERE EXISTS
left_join() Conservar todos los registros de la tabla base
inner_join() Solo coincidencias en ambas tablas
full_join() Unión total, sin descartar nada
duplicated() Identificar y eliminar filas repetidas
pivot_wider() De tidy a formato de reporte (largo → ancho)
pivot_longer() De reporte a tidy (ancho → largo)

Los joins bien aplicados, combinados con una auditoría previa con anti_join() y semi_join(), permiten construir tablas maestras confiables. La calidad del análisis depende directamente de la integridad de las relaciones entre tablas.