Estadística descriptiva con R

Análisis de la base PhiUSIIL Phishing URL (Website)

Ovaldino Palacio · Grupo:

Universidad de Panamá · Facultad de Informática · Licenciatura en Desarrollo de Aplicaciones

Asignatura: Estadística Descriptiva · Profesor: Abraham de Sedas

Herramienta: RStudio (R 4.6.1) · 15 de noviembre de 2026

UNIVERSIDAD DE PANAMÁ

FACULTAD DE INFORMÁTICA

Licenciatura en Desarrollo de Aplicaciones

Asignatura: Estadística Descriptiva

Parcial: análisis descriptivo de la base PhiUSIIL Phishing URL (Website)

Estudiante: Ovaldino Palacio

Cédula:

Grupo:

Profesor: Abraham de Sedas

Herramienta utilizada: RStudio (lenguaje R, versión 4.6.1)

15 de noviembre de 2026

1 Introducción

El phishing es una forma de fraude en la que una página web imita a un sitio de confianza para obtener datos de las personas. Este informe describe la base pública PhiUSIIL Phishing URL (Website), que reúne 235,795 direcciones URL clasificadas como legítimas o phishing (Prasad y Chandra, 2024a). El trabajo organiza, resume y representa un grupo de variables de esa base mediante tablas de frecuencia, medidas descriptivas y gráficos. El análisis es únicamente descriptivo: no predice, no prueba hipótesis y no establece causas.

2 Objetivos

2.1 Objetivo general

Describir la distribución, el comportamiento y la variabilidad de variables seleccionadas de la base PhiUSIIL mediante tablas, medidas y gráficos elaborados en RStudio.

2.2 Objetivos específicos

  1. Verificar la estructura, los tipos de variables, la integridad y el tamaño de la base.

  2. Construir tablas de frecuencia simples y agrupadas, con frecuencias absolutas, relativas y acumuladas cuando corresponda.

  3. Calcular medidas de tendencia central, posición y dispersión según el tipo de variable.

  4. Seleccionar gráficos adecuados e interpretar los resultados en el contexto de la ciberseguridad.

3 Metodología

Se trabajó con la base completa, sin muestreo y sin eliminar registros. El archivo PhiUSIIL_Phishing_URL_Dataset.csv se importó en RStudio, con el lenguaje R (R Core Team, 2026), mediante la función read_csv() del paquete readr. Las tablas y las medidas se calcularon con dplyr y tidyr, y los gráficos se elaboraron con ggplot2 (Wickham, 2016). El mismo procedimiento está en el script Parcial_Estadistica_Completo.R, que regenera las tablas, las figuras y este informe a partir del CSV original.

Las convenciones de cálculo fueron las siguientes. Los cuartiles se obtuvieron con la función quantile() en su método por defecto (tipo 7). La varianza y la desviación estándar se calcularon con var() y sd(), que usan el divisor n − 1; con 235,795 registros, la diferencia con el divisor n es despreciable. El coeficiente de variación (CV) se calculó como 100 × desviación estándar / media, solo para variables en escala de razón. Se consideró atípico todo valor fuera del intervalo [Q1 − 1.5 RIC, Q3 + 1.5 RIC]. Las frecuencias relativas se redondearon a tres decimales y los porcentajes a dos. En las comparaciones por clase, los porcentajes se calcularon dentro de cada clase, y así se indica en cada tabla.

La variable URL se conservó solo como identificador y la columna FILENAME se excluyó del análisis, como permite la ficha de UCI. La variable label se trató como cualitativa: sus códigos 1 y 0 son etiquetas y no cantidades.

paquetes <- c("readr", "dplyr", "tidyr", "ggplot2", "scales", "stringr",
              "knitr", "rmarkdown")
faltantes <- paquetes[!vapply(paquetes, requireNamespace, logical(1),
                              quietly = TRUE)]
if (length(faltantes) > 0) {
  message("Instalando paquetes faltantes: ", paste(faltantes, collapse = ", "))
  install.packages(faltantes)
}
suppressPackageStartupMessages({
  library(readr)
  library(dplyr)
  library(tidyr)
  library(ggplot2)
  library(scales)
  library(stringr)
})
message("[0] Paquetes cargados correctamente.")
# Se busca la carpeta raíz del proyecto a partir del directorio de trabajo.
archivo_proyecto <- "Parcial_Estadistica_Descriptiva.Rproj"
if (file.exists(archivo_proyecto)) {
  raiz <- "."
} else if (file.exists(file.path("..", archivo_proyecto))) {
  raiz <- ".."                       # caso: se ejecuta desde /scripts o /informe
} else {
  raiz <- "."                        # caso: se ejecuta fuera del proyecto
}
dir_datos    <- file.path(raiz, "datos")
dir_tablas   <- file.path(raiz, "tablas")
dir_graficos <- file.path(raiz, "graficos")
dir_informe  <- file.path(raiz, "informe")
for (carpeta in c(dir_datos, dir_tablas, dir_graficos, dir_informe)) {
  dir.create(carpeta, showWarnings = FALSE, recursive = TRUE)
}

4 Resultados

4.1 Revisión y preparación de los datos

nombre_csv <- "PhiUSIIL_Phishing_URL_Dataset.csv"
candidatos <- c(file.path(dir_datos, nombre_csv), file.path(raiz, nombre_csv))
ruta_csv   <- candidatos[file.exists(candidatos)][1]

if (is.na(ruta_csv)) {
  if (interactive()) {
    message("No se encontró el CSV en la carpeta /datos. Selecciónelo manualmente.")
    ruta_csv <- file.choose()
  } else {
    stop("No se encontró '", nombre_csv, "'. Cópielo en la carpeta 'datos' ",
         "del proyecto y vuelva a ejecutar.")
  }
}

datos <- tryCatch(
  read_csv(ruta_csv, show_col_types = FALSE, progress = FALSE),
  error = function(e) stop("Error al importar el CSV: ", conditionMessage(e))
)
problemas_importacion <- nrow(problems(datos))
message("[2] Base importada: ", format(nrow(datos), big.mark = ","), " filas y ",
        ncol(datos), " columnas.")
dim(datos)      # filas y columnas
#> [1] 235795     56
names(datos)    # nombres reales de las columnas
#>  [1] "FILENAME"                   "URL"                        "URLLength"                 
#>  [4] "Domain"                     "DomainLength"               "IsDomainIP"                
#>  [7] "TLD"                        "URLSimilarityIndex"         "CharContinuationRate"      
#> [10] "TLDLegitimateProb"          "URLCharProb"                "TLDLength"                 
#> [13] "NoOfSubDomain"              "HasObfuscation"             "NoOfObfuscatedChar"        
#> [16] "ObfuscationRatio"           "NoOfLettersInURL"           "LetterRatioInURL"          
#> [19] "NoOfDegitsInURL"            "DegitRatioInURL"            "NoOfEqualsInURL"           
#> [22] "NoOfQMarkInURL"             "NoOfAmpersandInURL"         "NoOfOtherSpecialCharsInURL"
#> [25] "SpacialCharRatioInURL"      "IsHTTPS"                    "LineOfCode"                
#> [28] "LargestLineLength"          "HasTitle"                   "Title"                     
#> [31] "DomainTitleMatchScore"      "URLTitleMatchScore"         "HasFavicon"                
#> [34] "Robots"                     "IsResponsive"               "NoOfURLRedirect"           
#> [37] "NoOfSelfRedirect"           "HasDescription"             "NoOfPopup"                 
#> [40] "NoOfiFrame"                 "HasExternalFormSubmit"      "HasSocialNet"              
#> [43] "HasSubmitButton"            "HasHiddenFields"            "HasPasswordField"          
#> [46] "Bank"                       "Pay"                        "Crypto"                    
#> [49] "HasCopyrightInfo"           "NoOfImage"                  "NoOfCSS"                   
#> [52] "NoOfJS"                     "NoOfSelfRef"                "NoOfEmptyRef"              
#> [55] "NoOfExternalRef"            "label"
n_total   <- nrow(datos)
n_columnas <- ncol(datos)

# Tipos de dato de las 56 columnas
tipo_columna <- vapply(datos, function(x) {
  if (is.character(x)) "Texto"
  else if (all(x == round(x))) "Numérica entera"
  else "Numérica decimal"
}, character(1))
tabla_tipos <- as.data.frame(table(tipo_columna), stringsAsFactors = FALSE)
names(tabla_tipos) <- c("Tipo de dato", "Columnas")

# Columnas binarias (solo toman los valores 0 y 1)
columnas_binarias <- names(datos)[vapply(datos, function(x)
  is.numeric(x) && all(x %in% c(0, 1)), logical(1))]
tabla_tipos
#>       Tipo de dato Columnas
#> 1 Numérica decimal       10
#> 2  Numérica entera       41
#> 3            Texto        5

La fuente es el UCI Machine Learning Repository. El archivo importado tiene 235,795 filas y 56 columnas: la columna FILENAME, las 54 características que reporta UCI y la variable label. La unidad de observación es una dirección URL junto con la página web a la que corresponde. La variable label indica la clasificación: 1 = URL legítima y 0 = URL phishing. De las 56 columnas, 41 son numéricas enteras, 10 son numéricas decimales y 5 son de texto; 20 de las numéricas solo toman los valores 0 y 1.

4.1.1 Variables seleccionadas y requisito WebTraffic

# Variables exigidas por el profesor y variables adicionales seleccionadas.
vars_obligatorias <- c("label", "URLLength", "DomainLength",
                       "URLSimilarityIndex", "TLDLegitimateProb", "WebTraffic")
vars_adicionales  <- c("URL", "Domain", "TLD", "IsHTTPS", "IsDomainIP",
                       "HasTitle", "NoOfSubDomain", "NoOfLettersInURL")

# Búsqueda sin distinguir mayúsculas, por si el archivo cambia el formato.
buscar_columna <- function(nombre, columnas) {
  coincide <- columnas[tolower(columnas) == tolower(nombre)]
  if (length(coincide) == 0) NA_character_ else coincide[1]
}
equivalencias <- tibble(
  variable_solicitada = c(vars_obligatorias, vars_adicionales),
  columna_en_csv = vapply(c(vars_obligatorias, vars_adicionales),
                          buscar_columna, character(1), columnas = names(datos))
)
vars_ausentes <- equivalencias$variable_solicitada[is.na(equivalencias$columna_en_csv)]

# WebTraffic figura en las instrucciones, pero NO existe en el CSV de UCI.
# Se documenta la ausencia; no se inventa ni se sustituye por otra variable.
existe_webtraffic <- !("WebTraffic" %in% vars_ausentes)
columnas_similares_trafico <- grep("traffic|rank|visit|popular", names(datos),
                                   ignore.case = TRUE, value = TRUE)
if (!existe_webtraffic) {
  message("[3] AVISO: la variable 'WebTraffic' no existe en el archivo. ",
          "Se documenta en la auditoría y se continúa con las demás.")
}
imprescindibles <- setdiff(c(vars_obligatorias, vars_adicionales), "WebTraffic")
if (any(imprescindibles %in% vars_ausentes)) {
  stop("Faltan columnas imprescindibles: ",
       paste(intersect(imprescindibles, vars_ausentes), collapse = ", "))
}
print(equivalencias, n = Inf)
#> # A tibble: 14 × 2
#>    variable_solicitada columna_en_csv    
#>    <chr>               <chr>             
#>  1 label               label             
#>  2 URLLength           URLLength         
#>  3 DomainLength        DomainLength      
#>  4 URLSimilarityIndex  URLSimilarityIndex
#>  5 TLDLegitimateProb   TLDLegitimateProb 
#>  6 WebTraffic          <NA>              
#>  7 URL                 URL               
#>  8 Domain              Domain            
#>  9 TLD                 TLD               
#> 10 IsHTTPS             IsHTTPS           
#> 11 IsDomainIP          IsDomainIP        
#> 12 HasTitle            HasTitle          
#> 13 NoOfSubDomain       NoOfSubDomain     
#> 14 NoOfLettersInURL    NoOfLettersInURL
columnas_similares_trafico      # columnas con nombre parecido a "tráfico"
#> character(0)

Las instrucciones piden incluir obligatoriamente la variable WebTraffic. Ese requisito no se puede cumplir con el archivo oficial: la columna no existe en el CSV descargado de UCI. Se revisaron los 56 nombres de columna, sin distinguir mayúsculas, y ninguno coincide con WebTraffic ni contiene un término equivalente (traffic, rank, visit o popular). La tabla de variables de la ficha de UCI tiene 56 entradas, el mismo número de columnas que el archivo. Por lo tanto no hay equivalencia que documentar y la variable no se pudo analizar. No se sustituyó por otra ni se simularon valores. Las otras cuatro variables cuantitativas obligatorias (URLLength, DomainLength, URLSimilarityIndex y TLDLegitimateProb) sí están en el archivo con el mismo nombre.

# Diccionario de las variables seleccionadas
# Nota: la ficha de UCI no trae descripciones; se redactaron a partir del
# nombre de cada variable y del artículo de Prasad y Chandra (2024).
diccionario <- tribble(
  ~Variable, ~Descripción, ~Tipo, ~Escala, ~`Valores o unidad`,
  "URL", "Dirección web completa; se usa solo como identificador", "Cualitativa", "Nominal", "Texto",
  "label", "Clasificación de la URL", "Cualitativa binaria", "Nominal", "1 = legítima; 0 = phishing",
  "IsHTTPS", "Indica si la URL usa el protocolo HTTPS", "Cualitativa binaria", "Nominal", "1 = sí; 0 = no",
  "IsDomainIP", "Indica si el dominio es una dirección IP", "Cualitativa binaria", "Nominal", "1 = sí; 0 = no",
  "HasTitle", "Indica si la página web tiene título", "Cualitativa binaria", "Nominal", "1 = sí; 0 = no",
  "TLD", "Dominio de nivel superior (com, org, net, ...)", "Cualitativa politómica", "Nominal",
    paste(n_distinct(datos$TLD), "categorías"),
  "URLLength", "Longitud de la URL", "Cuantitativa discreta", "Razón", "Caracteres",
  "DomainLength", "Longitud del dominio", "Cuantitativa discreta", "Razón", "Caracteres",
  "URLSimilarityIndex", "Índice de similitud de la URL calculado por los autores", "Cuantitativa continua", "Razón", "Puntos, de 0 a 100",
  "TLDLegitimateProb", "Probabilidad de legitimidad asociada al TLD, derivada por los autores", "Cuantitativa continua", "Razón", "Proporción, de 0 a 1",
  "NoOfLettersInURL", "Cantidad de letras en la URL", "Cuantitativa discreta", "Razón", "Letras",
  "NoOfSubDomain", "Cantidad de subdominios de la URL", "Cuantitativa discreta", "Razón", "Subdominios"
)

Se seleccionaron 11 variables de análisis, además de URL como identificador (Tabla 1): la variable de clase, cuatro cualitativas y seis cuantitativas. Así se cumple el mínimo de ocho variables, con al menos tres categóricas o binarias y al menos cuatro cuantitativas.

Tabla 1

Diccionario de las variables seleccionadas

Variable Descripción Tipo Escala Valores o unidad
URL Dirección web completa; se usa solo como identificador Cualitativa Nominal Texto
label Clasificación de la URL Cualitativa binaria Nominal 1 = legítima; 0 = phishing
IsHTTPS Indica si la URL usa el protocolo HTTPS Cualitativa binaria Nominal 1 = sí; 0 = no
IsDomainIP Indica si el dominio es una dirección IP Cualitativa binaria Nominal 1 = sí; 0 = no
HasTitle Indica si la página web tiene título Cualitativa binaria Nominal 1 = sí; 0 = no
TLD Dominio de nivel superior (com, org, net, …) Cualitativa politómica Nominal 695 categorías
URLLength Longitud de la URL Cuantitativa discreta Razón Caracteres
DomainLength Longitud del dominio Cuantitativa discreta Razón Caracteres
URLSimilarityIndex Índice de similitud de la URL calculado por los autores Cuantitativa continua Razón Puntos, de 0 a 100
TLDLegitimateProb Probabilidad de legitimidad asociada al TLD, derivada por los autores Cuantitativa continua Razón Proporción, de 0 a 1
NoOfLettersInURL Cantidad de letras en la URL Cuantitativa discreta Razón Letras
NoOfSubDomain Cantidad de subdominios de la URL Cuantitativa discreta Razón Subdominios

Nota. La ficha de UCI no incluye descripciones; se redactaron a partir del nombre de cada variable y del artículo de los autores (Prasad y Chandra, 2024b). Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

4.1.2 Calidad de los datos

# Calidad: faltantes, duplicados, valores imposibles e inconsistencias
columnas_numericas <- names(datos)[vapply(datos, is.numeric, logical(1))]
url_repetida       <- duplicated(datos$URL) | duplicated(datos$URL, fromLast = TRUE)
diferencia_longitud <- datos$URLLength - nchar(datos$URL)

calidad <- tibble(
  Verificación = c(
    "Filas importadas",
    "Columnas importadas",
    "Problemas de lectura reportados por readr",
    "Valores faltantes (NA) en toda la base",
    "Filas duplicadas exactas (sin FILENAME)",
    "URL repetidas (registros adicionales con la misma URL)",
    "Registros involucrados en URL repetidas",
    "Valores negativos en columnas numéricas",
    "label con valores distintos de 0 y 1",
    "URLSimilarityIndex fuera del rango 0-100",
    "TLDLegitimateProb fuera del rango 0-1",
    "URLLength menor o igual que 0",
    "DomainLength mayor que URLLength",
    "DomainLength distinto del número de caracteres de Domain",
    "URLLength igual al número de caracteres de URL",
    "URLLength un carácter menor que el texto de URL",
    "URLLength con otra diferencia respecto al texto de URL",
    "URL legítimas con URLLength = DomainLength + 7"
  ),
  Resultado = c(
    n_total,
    n_columnas,
    problemas_importacion,
    sum(is.na(datos)),
    sum(duplicated(select(datos, -FILENAME))),
    sum(duplicated(datos$URL)),
    sum(url_repetida),
    sum(vapply(datos[columnas_numericas], function(x) sum(x < 0), numeric(1))),
    sum(!datos$label %in% c(0, 1)),
    sum(datos$URLSimilarityIndex < 0 | datos$URLSimilarityIndex > 100),
    sum(datos$TLDLegitimateProb < 0 | datos$TLDLegitimateProb > 1),
    sum(datos$URLLength <= 0),
    sum(datos$DomainLength > datos$URLLength),
    sum(datos$DomainLength != nchar(datos$Domain)),
    sum(diferencia_longitud == 0),
    sum(diferencia_longitud == -1),
    sum(!diferencia_longitud %in% c(0, -1)),
    sum(datos$label == 1 & datos$URLLength == datos$DomainLength + 7)
  )
)
# Clase de los registros con URL repetida (para describirlos, no para borrarlos)
url_repetidas_por_clase <- table(datos$label[url_repetida])
# Contraste con la ficha de UCI
contraste_uci <- tibble(
  Aspecto = c("Registros", "URL legítimas (label = 1)",
              "URL phishing (label = 0)", "Valores faltantes"),
  `Ficha UCI` = c(235795, 134850, 100945, 0),
  `CSV importado` = c(n_total, sum(datos$label == 1), sum(datos$label == 0),
                      sum(is.na(datos)))
) |>
  mutate(Coincide = ifelse(`Ficha UCI` == `CSV importado`, "Sí", "No"))
contraste_uci
#> # A tibble: 4 × 4
#>   Aspecto                   `Ficha UCI` `CSV importado` Coincide
#>   <chr>                           <dbl>           <int> <chr>   
#> 1 Registros                      235795          235795 Sí      
#> 2 URL legítimas (label = 1)      134850          134850 Sí      
#> 3 URL phishing (label = 0)       100945          100945 Sí      
#> 4 Valores faltantes                   0               0 Sí

Los totales coinciden con la ficha de UCI: 134,850 URL legítimas, 100,945 URL phishing y 0 valores faltantes. Se encontró una diferencia de tipo: UCI lista URLSimilarityIndex como variable entera, pero en el archivo toma valores decimales entre 0.156 y 100, por lo que se trató como cuantitativa continua.

La revisión de calidad se resume en la Tabla 2. No hay valores faltantes, filas duplicadas exactas, valores negativos ni valores fuera de los rangos posibles. Hay 425 URL que aparecen más de una vez (850 registros involucrados, todos de la clase phishing). Esos registros no son duplicados exactos, porque difieren en otras columnas, así que se conservaron. También se observó una inconsistencia de formato: en 187,149 registros (79.37 %) el valor de URLLength es un carácter menor que el texto guardado en la columna URL. Se usó URLLength tal como viene en la base y la diferencia se reporta como limitación. No se eliminó ni se modificó ningún registro.

Tabla 2

Revisión de la calidad de los datos

Verificación Resultado
Filas importadas 235,795
Columnas importadas 56
Problemas de lectura reportados por readr 0
Valores faltantes (NA) en toda la base 0
Filas duplicadas exactas (sin FILENAME) 0
URL repetidas (registros adicionales con la misma URL) 425
Registros involucrados en URL repetidas 850
Valores negativos en columnas numéricas 0
label con valores distintos de 0 y 1 0
URLSimilarityIndex fuera del rango 0-100 0
TLDLegitimateProb fuera del rango 0-1 0
URLLength menor o igual que 0 0
DomainLength mayor que URLLength 0
DomainLength distinto del número de caracteres de Domain 0
URLLength igual al número de caracteres de URL 48,644
URLLength un carácter menor que el texto de URL 187,149
URLLength con otra diferencia respecto al texto de URL 2
URL legítimas con URLLength = DomainLength + 7 134,850

Nota. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

# Base de análisis: se conserva `datos` intacto y se agregan etiquetas
datos_analisis <- datos |>
  mutate(
    clase       = factor(label, levels = c(1, 0),
                         labels = c("Legítima", "Phishing")),
    https       = factor(IsHTTPS, levels = c(1, 0), labels = c("Sí", "No")),
    dominio_ip  = factor(IsDomainIP, levels = c(1, 0), labels = c("Sí", "No")),
    tiene_titulo = factor(HasTitle, levels = c(1, 0), labels = c("Sí", "No"))
  )
stopifnot(nrow(datos_analisis) == n_total)   # no se eliminó ningún registro
message("[4] Auditoría completada: ", sum(is.na(datos)), " faltantes, ",
        calidad$Resultado[5], " filas duplicadas exactas.")

4.2 Tablas de frecuencia

En todas las tablas, fi es la frecuencia absoluta, hi la frecuencia relativa, Fi la frecuencia absoluta acumulada y Hi la frecuencia relativa acumulada. Las acumuladas se presentan solo cuando la variable tiene un orden. Todas las tablas suman 235,795 registros.

# Notación: fi = frecuencia absoluta; hi = frecuencia relativa;
#           Fi = absoluta acumulada; Hi = relativa acumulada.

# Tabla Tipo I: una fila por categoría o valor observado.
# Las acumuladas solo se agregan si la variable tiene orden (acumular = TRUE).
tabla_tipo1 <- function(x, nombre, acumular = FALSE) {
  t <- as.data.frame(table(x), stringsAsFactors = FALSE)
  names(t) <- c(nombre, "fi")
  t$hi <- t$fi / sum(t$fi)
  t$`%` <- 100 * t$hi
  if (acumular) {
    t$Fi <- cumsum(t$fi)
    t$Hi <- cumsum(t$hi)
  }
  t
}

# Tabla con clases (Tipo II y Tipo III). `cortes` son los límites inferiores
# de cada clase más el límite superior de la última; clases [Li, Ls).
tabla_clases <- function(x, cortes, etiquetas, limite_superior_final = NULL) {
  clase <- cut(x, breaks = cortes, right = FALSE, labels = etiquetas)
  fi <- as.vector(table(clase))
  li <- head(cortes, -1)
  ls <- tail(cortes, -1)
  if (!is.null(limite_superior_final)) ls[length(ls)] <- limite_superior_final
  tibble(
    Clase = etiquetas, Li = li, Ls = ls,
    Amplitud = ls - li, `Marca de clase` = (li + ls) / 2,
    fi = fi, hi = fi / sum(fi), `%` = 100 * fi / sum(fi),
    Fi = cumsum(fi), Hi = cumsum(fi) / sum(fi)
  )
}

4.2.1 Tablas Tipo I: valores individuales

# --- Tabla 3 (Tipo I): label ---------------------------------------------------
tabla_label <- tabla_tipo1(datos_analisis$clase, "Clasificación")

La Tabla 3 presenta la variable de clase. La base tiene 134,850 URL legítimas (57.19 %) y 100,945 URL phishing (42.81 %). La variable es nominal, por lo que no se acumula.

Tabla 3

Tabla Tipo I: distribución de las URL según su clasificación (label)

Clasificación fi hi %
Legítima 134,850 0.572 57.19
Phishing 100,945 0.428 42.81
Total 235,795 1.000 100.00

Nota. label = 1 corresponde a legítima y label = 0 a phishing. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

# --- Tabla 4 (Tipo I): IsHTTPS -------------------------------------------------
tabla_https <- tabla_tipo1(datos_analisis$https, "Usa HTTPS")

La Tabla 4 muestra que 184,539 URL (78.26 %) usan el protocolo HTTPS y 51,256 (21.74 %) no lo usan.

Tabla 4

Tabla Tipo I: distribución de las URL según el uso del protocolo HTTPS (IsHTTPS)

Usa HTTPS fi hi %
Sí 184,539 0.783 78.26
No 51,256 0.217 21.74
Total 235,795 1.000 100.00

Nota. Sí corresponde a IsHTTPS = 1 y No a IsHTTPS = 0. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

# --- Tabla 5 (Tipo I): TLD, 10 más frecuentes y el resto agrupado ----------------
# TLD tiene cientos de categorías; el profesor pide agrupar las menos frecuentes.
n_tld_top <- 10
conteo_tld <- sort(table(datos$TLD), decreasing = TRUE)
tld_top <- names(conteo_tld)[seq_len(n_tld_top)]
n_tld_otros <- length(conteo_tld) - n_tld_top
etiqueta_otros <- paste0("Otros (", n_tld_otros, " TLD)")
tabla_tld <- tibble(
  TLD = c(paste0(".", tld_top), etiqueta_otros),
  fi  = c(as.vector(conteo_tld[tld_top]), sum(conteo_tld[-seq_len(n_tld_top)]))
) |>
  mutate(hi = fi / sum(fi), `%` = 100 * hi)

La variable TLD tiene 695 categorías distintas. Como indica el profesor para variables con demasiadas categorías, la Tabla 5 presenta las 10 más frecuentes y agrupa las 685 restantes en la categoría “Otros”. El TLD más frecuente es .com, con 112,554 URL (47.73 %).

Tabla 5

Tabla Tipo I: distribución de las URL según el dominio de nivel superior (TLD)

TLD fi hi %
.com 112,554 0.477 47.73
.org 18,793 0.080 7.97
.net 7,097 0.030 3.01
.app 6,508 0.028 2.76
.uk 6,395 0.027 2.71
.co 5,422 0.023 2.30
.io 4,201 0.018 1.78
.de 3,996 0.017 1.69
.ru 3,875 0.016 1.64
.au 2,979 0.013 1.26
Otros (685 TLD) 63,975 0.271 27.13
Total 235,795 1.000 100.00

Nota. Las categorías menos frecuentes se agruparon en Otros. Variable nominal: no se acumula. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

# --- Tabla 6 (Tipo I con acumuladas): NoOfSubDomain ------------------------------
# Variable discreta con pocos valores y con orden: admite Fi y Hi.
tabla_subdominios <- tabla_tipo1(datos$NoOfSubDomain, "Subdominios",
                                 acumular = TRUE)

La Tabla 6 presenta la cantidad de subdominios. Es una variable discreta con pocos valores y con orden, por lo que admite frecuencias acumuladas. El valor más frecuente es 1 subdominio, con 178,406 URL (75.66 %); el 97.01 % de las URL tiene 2 subdominios o menos.

Tabla 6

Tabla Tipo I con frecuencias acumuladas: cantidad de subdominios de la URL (NoOfSubDomain)

Subdominios fi hi % Fi Hi
0 13,983 0.059 5.93 13,983 0.059
1 178,406 0.757 75.66 192,389 0.816
2 36,353 0.154 15.42 228,742 0.970
3 5,119 0.022 2.17 233,861 0.992
4 1,563 0.007 0.66 235,424 0.998
5 336 0.001 0.14 235,760 1.000
6 21 0.000 0.01 235,781 1.000
7 5 0.000 0.00 235,786 1.000
8 4 0.000 0.00 235,790 1.000
10 5 0.000 0.00 235,795 1.000

Nota. En la base no hay URL con 9 subdominios. Un valor de 0.000 indica una proporción menor que 0.0005. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

4.2.2 Tabla Tipo II: valores discretos agrupados

# --- Tabla 7 (Tipo II): NoOfLettersInURL en clases discretas ---------------------
# Clases de 10 valores (0-9, 10-19, ...) y una clase final que reúne la cola.
max_letras <- max(datos$NoOfLettersInURL)
cortes_letras <- c(seq(0, 60, by = 10), max_letras + 1)
etiquetas_letras <- c(paste(seq(0, 50, 10), "-", seq(9, 59, 10)),
                      paste("60 -", max_letras))
tabla_letras <- tabla_clases(datos$NoOfLettersInURL, cortes_letras,
                             etiquetas_letras) |>
  mutate(Ls = Ls - 1,                         # límites discretos: 0-9, 10-19...
         Amplitud = Ls - Li + 1,              # cantidad de valores por clase
         `Marca de clase` = (Li + Ls) / 2)

La cantidad de letras de la URL es una variable discreta que toma 421 valores distintos, entre 0 y 5,191. Se agrupó en clases de 10 valores consecutivos (0 a 9, 10 a 19, y así sucesivamente). Como los valores de 60 o más son poco frecuentes y muy dispersos, se reunieron en una clase final. La clase con mayor frecuencia es la de 10 - 19 letras, con 125,614 URL (53.27 %) (Tabla 7).

Tabla 7

Tabla Tipo II: cantidad de letras de la URL agrupada en clases discretas (NoOfLettersInURL)

Clase (letras) Li Ls Amplitud fi hi % Fi Hi
0 - 9 0 9 10 46,763 0.198 19.83 46,763 0.198
10 - 19 10 19 10 125,614 0.533 53.27 172,377 0.731
20 - 29 20 29 10 35,366 0.150 15.00 207,743 0.881
30 - 39 30 39 10 11,345 0.048 4.81 219,088 0.929
40 - 49 40 49 10 4,708 0.020 2.00 223,796 0.949
50 - 59 50 59 10 3,413 0.014 1.45 227,209 0.964
60 - 5191 60 5,191 5,132 8,586 0.036 3.64 235,795 1.000

Nota. Li y Ls son el menor y el mayor valor de cada clase. La amplitud es la cantidad de valores enteros de la clase; la última clase reúne la cola de la distribución. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

4.2.3 Tablas Tipo III: intervalos para variables cuantitativas

# --- Tabla 8 (Tipo III): URLLength ----------------------------------------------
# Regla de Sturges como referencia: k = 1 + 3.322 log10(n)
k_sturges <- ceiling(1 + 3.322 * log10(n_total))
rango_url <- max(datos$URLLength) - min(datos$URLLength)
amplitud_sturges_url <- ceiling(rango_url / k_sturges)
# Con esa amplitud casi toda la base cae en la primera clase (se calcula abajo),
# por lo que se usan clases de 10 caracteres y una clase final para la cola.
pct_primera_clase_sturges <- 100 * mean(
  datos$URLLength < min(datos$URLLength) + amplitud_sturges_url)
max_url <- max(datos$URLLength)
cortes_url <- c(seq(10, 100, by = 10), max_url + 1)
etiquetas_url <- c(paste0("[", seq(10, 90, 10), ", ", seq(20, 100, 10), ")"),
                   paste0("[100, ", max_url, "]"))
tabla_urllength <- tabla_clases(datos$URLLength, cortes_url, etiquetas_url,
                                limite_superior_final = max_url)
c(k_sturges = k_sturges, rango = rango_url, amplitud_sturges = amplitud_sturges_url,
  pct_primera_clase = round(pct_primera_clase_sturges, 2))
#>         k_sturges             rango  amplitud_sturges pct_primera_clase 
#>             19.00           6084.00            321.00             99.79

Para la longitud de la URL se tomó como referencia la regla de Sturges, k = 1 + 3.322 log(n), que da 19 clases. El rango es de 6,084 caracteres, así que la amplitud sería de 321 caracteres y el 99.79 % de las URL quedaría en la primera clase, lo que no describe la distribución. Por esa razón se usaron clases de 10 caracteres entre 10 y 100, donde se concentra la mayor parte de los datos, y una clase final de 100 caracteres o más. Las clases son cerradas a la izquierda y abiertas a la derecha, de modo que no se superponen. La clase modal es [20, 30), con 129,295 URL (54.83 %) (Tabla 8).

Tabla 8

Tabla Tipo III: longitud de la URL en intervalos (URLLength, en caracteres)

Clase Li Ls Amplitud Marca de clase fi hi Fi Hi
[10, 20) 10 20 10 15.0 12,961 0.055 12,961 0.055
[20, 30) 20 30 10 25.0 129,295 0.548 142,256 0.603
[30, 40) 30 40 10 35.0 54,560 0.231 196,816 0.835
[40, 50) 40 50 10 45.0 15,272 0.065 212,088 0.899
[50, 60) 50 60 10 55.0 7,460 0.032 219,548 0.931
[60, 70) 60 70 10 65.0 3,342 0.014 222,890 0.945
[70, 80) 70 80 10 75.0 2,605 0.011 225,495 0.956
[80, 90) 80 90 10 85.0 3,092 0.013 228,587 0.969
[90, 100) 90 100 10 95.0 2,232 0.009 230,819 0.979
[100, 6097] 100 6,097 5,997 3,098.5 4,976 0.021 235,795 1.000

Nota. La última clase es cerrada en ambos extremos y llega hasta el valor máximo observado (6,097 caracteres). Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

# --- Tabla 9 (Tipo III): DomainLength con la regla de Sturges --------------------
min_dom <- min(datos$DomainLength)
max_dom <- max(datos$DomainLength)
rango_dom <- max_dom - min_dom
amplitud_dom <- ceiling(rango_dom / k_sturges)
# Con la amplitud redondeada hacia arriba pueden sobrar clases vacías al final;
# se conservan solo las necesarias para cubrir hasta el valor máximo.
n_clases_dom <- ceiling((rango_dom + 1) / amplitud_dom)
cortes_dom <- min_dom + amplitud_dom * (0:n_clases_dom)
etiquetas_dom <- paste0("[", head(cortes_dom, -1), ", ", tail(cortes_dom, -1), ")")
tabla_domainlength <- tabla_clases(datos$DomainLength, cortes_dom, etiquetas_dom)
c(rango = rango_dom, amplitud = amplitud_dom, clases = n_clases_dom)
#>    rango amplitud   clases 
#>      106        6       18

Para la longitud del dominio sí se aplicó la regla de Sturges. Con k = 19 y un rango de 106 caracteres (de 4 a 110), la amplitud es 106 / 19 = 5.58, que se redondeó hacia arriba a 6 caracteres. Con esa amplitud bastan 18 clases para cubrir todos los valores. La clase modal es [16, 22), con 92,217 URL (39.11 %) (Tabla 9).

Tabla 9

Tabla Tipo III: longitud del dominio en intervalos (DomainLength, en caracteres)

Clase Li Ls Amplitud Marca de clase fi hi Fi Hi
[4, 10) 4 10 6 7 3,529 0.015 3,529 0.015
[10, 16) 10 16 6 13 48,773 0.207 52,302 0.222
[16, 22) 16 22 6 19 92,217 0.391 144,519 0.613
[22, 28) 22 28 6 25 53,932 0.229 198,451 0.842
[28, 34) 28 34 6 31 19,245 0.082 217,696 0.923
[34, 40) 34 40 6 37 8,017 0.034 225,713 0.957
[40, 46) 40 46 6 43 4,442 0.019 230,155 0.976
[46, 52) 46 52 6 49 2,378 0.010 232,533 0.986
[52, 58) 52 58 6 55 914 0.004 233,447 0.990
[58, 64) 58 64 6 61 259 0.001 233,706 0.991
[64, 70) 64 70 6 67 124 0.001 233,830 0.992
[70, 76) 70 76 6 73 1,249 0.005 235,079 0.997
[76, 82) 76 82 6 79 602 0.003 235,681 1.000
[82, 88) 82 88 6 85 65 0.000 235,746 1.000
[88, 94) 88 94 6 91 33 0.000 235,779 1.000
[94, 100) 94 100 6 97 6 0.000 235,785 1.000
[100, 106) 100 106 6 103 9 0.000 235,794 1.000
[106, 112) 106 112 6 109 1 0.000 235,795 1.000

Nota. Clases cerradas a la izquierda y abiertas a la derecha. Un valor de 0.000 indica una proporción menor que 0.0005. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

4.2.4 Comprobación de las tablas

# --- Comprobaciones de coherencia -------------------------------------------------
tablas_frecuencia <- list(label = tabla_label, https = tabla_https,
                          tld = tabla_tld, subdominios = tabla_subdominios,
                          letras = tabla_letras, urllength = tabla_urllength,
                          domainlength = tabla_domainlength)
comprobacion_tablas <- tibble(
  Tabla = names(tablas_frecuencia),
  `Suma de fi` = vapply(tablas_frecuencia, function(t) sum(t$fi), numeric(1)),
  `Suma de hi` = vapply(tablas_frecuencia, function(t) sum(t$hi), numeric(1))
) |>
  mutate(Correcta = `Suma de fi` == n_total & abs(`Suma de hi` - 1) < 1e-9)
stopifnot(all(comprobacion_tablas$Correcta))
message("[5] Tablas de frecuencia construidas: todas suman ",
        format(n_total, big.mark = ","), " registros.")
comprobacion_tablas
#> # A tibble: 7 × 4
#>   Tabla        `Suma de fi` `Suma de hi` Correcta
#>   <chr>               <dbl>        <dbl> <lgl>   
#> 1 label              235795            1 TRUE    
#> 2 https              235795            1 TRUE    
#> 3 tld                235795            1 TRUE    
#> 4 subdominios        235795            1 TRUE    
#> 5 letras             235795            1 TRUE    
#> 6 urllength          235795            1 TRUE    
#> 7 domainlength       235795            1 TRUE

Se comprobó que la suma de las frecuencias absolutas de cada tabla es 235,795 y que la suma de las frecuencias relativas es 1. Las 7 tablas cumplen ambas condiciones.

4.3 Medidas descriptivas

4.3.1 Variables cuantitativas

# Convenciones:
#  - Cuartiles: quantile() tipo 7 (método por defecto de R).
#  - Varianza y desviación estándar: var() y sd(), con divisor n - 1.
#  - CV = 100 * desviación estándar / media (solo variables de razón).
#  - Atípico: valor fuera de [Q1 - 1.5 RIC, Q3 + 1.5 RIC] (criterio de Tukey).

moda <- function(x) {
  t <- table(x)
  as.numeric(names(t)[which.max(t)])
}

medidas <- function(x) {
  q <- quantile(x, c(.25, .50, .75), names = FALSE)
  ric <- q[3] - q[1]
  c(`n válido` = sum(!is.na(x)),
    Media = mean(x), Mediana = median(x), Moda = moda(x),
    Mínimo = min(x), Máximo = max(x), Rango = max(x) - min(x),
    Q1 = q[1], Q2 = q[2], Q3 = q[3], RIC = ric,
    Varianza = var(x), `Desviación estándar` = sd(x),
    `CV (%)` = 100 * sd(x) / mean(x),
    # Si RIC = 0 el criterio de Tukey no es informativo: se deja en blanco (NA)
    `Atípicos superiores` = if (ric > 0) sum(x > q[3] + 1.5 * ric) else NA,
    `Atípicos inferiores` = if (ric > 0) sum(x < q[1] - 1.5 * ric) else NA)
}
vars_cuantitativas <- c("URLLength", "DomainLength", "URLSimilarityIndex",
                        "TLDLegitimateProb", "NoOfLettersInURL", "NoOfSubDomain")
if (existe_webtraffic) vars_cuantitativas <- c(vars_cuantitativas, "WebTraffic")

# Tabla 10: medidas de toda la base (una columna por variable)
matriz_medidas <- sapply(datos[vars_cuantitativas], medidas)
tabla_medidas <- tibble(Medida = rownames(matriz_medidas)) |>
  bind_cols(as_tibble(matriz_medidas))

La Tabla 10 reúne las medidas de las 6 variables cuantitativas para toda la base. Todas están en escala de razón, con un cero que significa ausencia, por lo que el coeficiente de variación es interpretable. En URLSimilarityIndex y TLDLegitimateProb el CV debe leerse con cautela, porque sus valores están acotados.

Tabla 10

Medidas descriptivas de las variables cuantitativas (base completa)

Medida URL Length Domain Length URL Similarity Index TLD Legitimate Prob NoOf Letters InURL NoOf Sub Domain
n válido 235,795 235,795 235,795 235,795 235,795 235,795
Media 34.573 21.470 78.431 0.260 19.429 1.165
Mediana 27.000 20.000 100.000 0.080 14.000 1.000
Moda 26.000 18.000 100.000 0.523 12.000 1.000
Mínimo 13.000 4.000 0.156 0.000 0.000 0.000
Máximo 6,097.000 110.000 100.000 0.523 5,191.000 10.000
Rango 6,084.000 106.000 99.844 0.523 5,191.000 10.000
Q1 23.000 16.000 57.025 0.006 10.000 1.000
Q2 27.000 20.000 100.000 0.080 14.000 1.000
Q3 34.000 24.000 100.000 0.523 20.000 1.000
RIC 11.000 8.000 42.975 0.517 10.000 0.000
Varianza 1,706.859 83.737 839.612 0.063 846.247 0.361
Desviación estándar 41.314 9.151 28.976 0.252 29.090 0.601
CV (%) 119.498 42.621 36.945 96.623 149.727 51.596
Atípicos superiores 22,493 13,474 0 0 19,662 —
Atípicos inferiores 0 0 0 0 0 —

Nota. Los nombres de las variables se muestran partidos en varias líneas. Q2 coincide con la mediana. RIC = Q3 − Q1. Varianza y desviación estándar con divisor n − 1. Atípicos según el criterio de Tukey; en NoOfSubDomain el RIC es 0 y el criterio no es informativo. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

En la longitud de la URL, la media (34.57 caracteres) es mayor que la mediana (27) y que la moda (26), lo que indica asimetría hacia la derecha. La mitad central de las URL mide entre 23 y 34 caracteres (RIC = 11), pero el máximo llega a 6,097; hay 22,493 valores atípicos superiores (9.54 % de la base). Esos valores extremos elevan la desviación estándar a 41.31 caracteres y el CV a 119.50 %, por lo que la mediana y el RIC resumen mejor esta variable que la media y la desviación estándar. La longitud del dominio es más homogénea: su media es 21.47, su mediana 20 y su CV 42.62 %.

En URLSimilarityIndex la mediana y el tercer cuartil valen 100, es decir, al menos la mitad de las URL tiene el valor máximo del índice; la media (78.43) queda por debajo, lo que señala asimetría hacia la izquierda. En TLDLegitimateProb la moda (0.523) coincide con el máximo y corresponde al TLD .com, mientras que la mediana es 0.080.

4.3.2 Variables categóricas

# Tabla 11: variables categóricas (moda, frecuencia y porcentaje de la moda)
resumen_categorica <- function(x, nombre) {
  t <- sort(table(x), decreasing = TRUE)
  tibble(Variable = nombre, Categorías = length(t),
         Moda = names(t)[1], `fi de la moda` = as.vector(t[1]),
         `% de la moda` = 100 * as.vector(t[1]) / sum(t))
}
tabla_categoricas <- bind_rows(
  resumen_categorica(datos_analisis$clase, "label"),
  resumen_categorica(datos_analisis$https, "IsHTTPS"),
  resumen_categorica(datos_analisis$dominio_ip, "IsDomainIP"),
  resumen_categorica(datos_analisis$tiene_titulo, "HasTitle"),
  resumen_categorica(paste0(".", datos$TLD), "TLD")
)

Para las variables categóricas se presenta la moda con su frecuencia y su porcentaje (Tabla 11). No se calcularon medias ni varianzas de estas variables, porque sus códigos son etiquetas.

Tabla 11

Moda de las variables categóricas

Variable Categorías Moda fi de la moda % de la moda
label 2 Legítima 134,850 57.19
IsHTTPS 2 Sí 184,539 78.26
IsDomainIP 2 No 235,157 99.73
HasTitle 2 Sí 203,081 86.13
TLD 695 .com 112,554 47.73

Nota. En las variables binarias, Sí corresponde al código 1 y No al código 0. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

4.3.3 Comparación entre URL legítimas y phishing

# Comparación por clase (legítima / phishing)
vars_comparacion <- c("URLLength", "DomainLength", "URLSimilarityIndex",
                      "TLDLegitimateProb")
tabla_comparacion <- bind_rows(lapply(vars_comparacion, function(v) {
  bind_rows(lapply(levels(datos_analisis$clase), function(g) {
    m <- medidas(datos_analisis[[v]][datos_analisis$clase == g])
    tibble(Variable = v, Clase = g, n = m["n válido"], Media = m["Media"],
           Mediana = m["Mediana"], Q1 = m["Q1"], Q3 = m["Q3"], RIC = m["RIC"],
           `Desv. est.` = m["Desviación estándar"], `CV (%)` = m["CV (%)"],
           Mínimo = m["Mínimo"], Máximo = m["Máximo"])
  }))
}))

# Para que quepa en la página, la comparación se presenta en dos tablas:
# Tabla 12 (centro y posición) y Tabla 13 (dispersión).
tabla_comparacion_centro <- tabla_comparacion |>
  select(Variable, Clase, n, Media, Mediana, Q1, Q3)
tabla_comparacion_dispersion <- tabla_comparacion |>
  select(Variable, Clase, Mínimo, Máximo, RIC, `Desv. est.`, `CV (%)`)

# Dato de apoyo para la interpretación del índice de similitud
phishing_indice_100 <- sum(datos$label == 0 & datos$URLSimilarityIndex == 100)
# Dato de apoyo para las limitaciones: URL phishing formadas solo por
# protocolo y dominio (misma estructura que todas las legítimas)
phishing_solo_dominio <- sum(datos$label == 0 &
                               datos$URLLength == datos$DomainLength + 7)
# Tabla 14: variables binarias según la clase (porcentaje DENTRO de cada clase)
tabla_binarias_clase <- datos_analisis |>
  select(clase, IsHTTPS, IsDomainIP, HasTitle) |>
  pivot_longer(-clase, names_to = "Variable", values_to = "valor") |>
  group_by(Variable, Clase = clase) |>
  summarise(n = n(), `fi (valor = 1)` = sum(valor),
            `% dentro de la clase` = 100 * mean(valor), .groups = "drop") |>
  mutate(Variable = factor(Variable, c("IsHTTPS", "HasTitle", "IsDomainIP"))) |>
  arrange(Variable, Clase) |>
  mutate(Variable = as.character(Variable))
message("[6] Medidas descriptivas calculadas para ",
        length(vars_cuantitativas), " variables cuantitativas.")

Las Tablas 12 y 13 comparan cuatro variables cuantitativas entre las dos clases. La Tabla 14 hace lo mismo con las variables binarias, con porcentajes calculados dentro de cada clase.

Tabla 12

Medidas de centro y posición por clasificación de la URL

Variable Clase n Media Mediana Q1 Q3
URLLength Legítima 134,850 26.229 26.000 23.000 29.000
URLLength Phishing 100,945 45.720 34.000 26.000 48.000
DomainLength Legítima 134,850 19.229 19.000 16.000 22.000
DomainLength Phishing 100,945 24.465 22.000 16.000 29.000
URLSimilarityIndex Legítima 134,850 100.000 100.000 100.000 100.000
URLSimilarityIndex Phishing 100,945 49.617 51.419 32.121 67.251
TLDLegitimateProb Legítima 134,850 0.282 0.523 0.018 0.523
TLDLegitimateProb Phishing 100,945 0.232 0.018 0.002 0.523

Nota. URLLength y DomainLength en caracteres; URLSimilarityIndex en puntos de 0 a 100; TLDLegitimateProb en proporción. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

Tabla 13

Medidas de dispersión por clasificación de la URL

Variable Clase Mínimo Máximo RIC Desv. est. CV (%)
URLLength Legítima 15.000 57.000 6.000 4.816 18.360
URLLength Phishing 13.000 6,097.000 22.000 61.146 133.738
DomainLength Legítima 8.000 50.000 6.000 4.816 25.044
DomainLength Phishing 4.000 110.000 13.000 12.204 49.883
URLSimilarityIndex Legítima 100.000 100.000 0.000 0.000 0.000
URLSimilarityIndex Phishing 0.156 100.000 35.131 22.572 45.493
TLDLegitimateProb Legítima 0.000 0.523 0.505 0.247 87.752
TLDLegitimateProb Phishing 0.000 0.523 0.521 0.255 109.770

Nota. RIC = rango intercuartílico; Desv. est. = desviación estándar; CV = coeficiente de variación. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

Tabla 14

Variables binarias según la clasificación de la URL (porcentaje dentro de cada clase)

Variable Clase n fi (valor = 1) % dentro de la clase
IsHTTPS Legítima 134,850 134,850 100.00
IsHTTPS Phishing 100,945 49,689 49.22
HasTitle Legítima 134,850 134,682 99.88
HasTitle Phishing 100,945 68,399 67.76
IsDomainIP Legítima 134,850 0 0.00
IsDomainIP Phishing 100,945 638 0.63

Nota. n es el total de URL de la clase; el porcentaje se calcula sobre ese total y no sobre toda la base. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

En las URL legítimas la longitud tiene una mediana de 26 caracteres y un RIC de 6; en las phishing la mediana es 34 y el RIC 22. La desviación estándar es de 4.82 caracteres en las legítimas y de 61.15 en las phishing. En esta base, entonces, las URL phishing son más largas en el centro de la distribución y mucho más variables. En URLSimilarityIndex todas las URL legítimas tienen el valor 100 (desviación estándar 0), mientras que las phishing tienen una mediana de 51.42 y un RIC de 35.13 puntos. Estas son diferencias observadas en los registros; el análisis no permite afirmar a qué se deben.

4.4 Gráficos estadísticos

Todas las figuras se elaboraron con ggplot2, con el mismo tema y la misma paleta: azul para las URL legítimas y rojo para las phishing. Cada figura se guarda en la carpeta graficos a 300 ppp.

# Identidad visual única para todas las figuras.
color_legitima <- "#1F4E79"    # azul sobrio
color_phishing <- "#B03A2E"    # rojo ladrillo
color_neutro   <- "#3E5C76"    # azul grisáceo para una sola serie
color_linea    <- "#1B1B1B"
colores_clase  <- c("Legítima" = color_legitima, "Phishing" = color_phishing)
fuente_grafico <- "Fuente: elaboración propia en RStudio con datos de Prasad y Chandra (2024), UCI Machine Learning Repository."

tema_parcial <- function() {
  theme_minimal(base_size = 12) +
    theme(
      text = element_text(colour = "black"),
      plot.title = element_text(face = "bold", size = 14),
      plot.subtitle = element_text(size = 11, margin = margin(b = 10)),
      plot.caption = element_text(size = 8.5, hjust = 0, margin = margin(t = 10)),
      plot.title.position = "plot",
      plot.caption.position = "plot",
      axis.title = element_text(size = 11),
      axis.text = element_text(colour = "black"),
      panel.grid.minor = element_blank(),
      panel.grid.major.x = element_blank(),
      legend.position = "top",
      legend.title = element_text(size = 10.5),
      plot.margin = margin(12, 16, 10, 12)
    )
}
miles <- function(x) format(x, big.mark = ",", trim = TRUE)
# Función para guardar cada figura con el mismo tamaño y resolución
guardar_figura <- function(figura, nombre) {
  ggsave(file.path(dir_graficos, paste0(nombre, ".png")), figura,
         width = 8, height = 5.2, dpi = 300, bg = "white")
}

4.4.1 Figura 1: barras de label

# --- Figura 1: barras de label ----------------------------------------------------
fig1 <- ggplot(tabla_label, aes(x = Clasificación, y = fi, fill = Clasificación)) +
  geom_col(width = 0.55, show.legend = FALSE) +
  geom_text(aes(label = paste0(miles(fi), "\n(", sprintf("%.2f", `%`), " %)")),
            vjust = -0.25, size = 3.9, lineheight = 0.95) +
  scale_fill_manual(values = colores_clase) +
  scale_y_continuous(labels = label_comma(), expand = expansion(mult = c(0, 0.18))) +
  labs(title = "Distribución de las URL según su clasificación",
       subtitle = paste0("Base completa PhiUSIIL, n = ", miles(n_total), " URL"),
       x = "Clasificación de la URL (label)", y = "Frecuencia absoluta (URL)",
       caption = fuente_grafico) +
  tema_parcial()
guardar_figura(fig1, "Figura_1_barras_label")

Figura 1

Gráfico de barras de las URL según su clasificación (label)

Nota. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

La Figura 1 representa la frecuencia de cada clase. Hay 134,850 URL legítimas (57.19 %) y 100,945 URL phishing (42.81 %), una diferencia de 33,905 registros. Las dos clases tienen un tamaño suficiente para describirlas por separado. Para un equipo de seguridad, esta proporción describe la composición de la base y no debe leerse como la frecuencia del phishing en internet.

4.4.2 Figura 2: Pareto de TLD

# --- Figura 2: diagrama de Pareto de TLD ------------------------------------------
pareto_tld <- tabla_tld |>
  mutate(TLD = factor(TLD, levels = TLD), acumulado = cumsum(`%`))
escala_pareto <- n_total / 100          # 1 % equivale a n_total / 100 URL
fig2 <- ggplot(pareto_tld, aes(x = TLD)) +
  geom_col(aes(y = fi), fill = color_neutro, width = 0.7) +
  geom_text(aes(y = fi, label = sprintf("%.1f %%", `%`)), vjust = -0.5, size = 3.1) +
  geom_line(aes(y = acumulado * escala_pareto, group = 1),
            colour = color_phishing, linewidth = 0.8) +
  geom_point(aes(y = acumulado * escala_pareto), colour = color_phishing, size = 2) +
  scale_y_continuous(
    labels = label_comma(), limits = c(0, n_total * 1.03),
    expand = expansion(mult = c(0, 0.02)),
    sec.axis = sec_axis(~ . / escala_pareto, name = "Porcentaje acumulado (%)",
                        breaks = seq(0, 100, 20))) +
  labs(title = "Diagrama de Pareto del dominio de nivel superior (TLD)",
       subtitle = paste0("Los ", n_tld_top, " TLD más frecuentes y el resto agrupado; ",
                         "barras = frecuencia, línea = porcentaje acumulado"),
       x = "Dominio de nivel superior (TLD)", y = "Frecuencia absoluta (URL)",
       caption = fuente_grafico) +
  tema_parcial() +
  theme(axis.text.x = element_text(angle = 35, hjust = 1))
guardar_figura(fig2, "Figura_2_pareto_TLD")

Figura 2

Diagrama de Pareto del dominio de nivel superior (TLD)

Nota. Las barras muestran la frecuencia absoluta y la línea el porcentaje acumulado. La categoría Otros se ubica al final por convención. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

La Figura 2 ordena los TLD de mayor a menor frecuencia. El TLD .com reúne el 47.73 % de las URL y los 10 TLD más frecuentes acumulan el 72.87 %; los otros 685 TLD se reparten el 27.13 % restante. La variable está muy concentrada en pocas categorías, lo que justifica agrupar las demás al resumirla.

4.4.3 Figura 3: histograma y polígono de URLLength

# --- Figura 3: histograma y polígono de frecuencias de URLLength -------------------
# Se grafican las nueve clases de amplitud 10; la clase final [100, máx] se
# informa en una nota porque su amplitud impide dibujarla a la misma escala.
clases_url_10 <- tabla_urllength[-nrow(tabla_urllength), ]
cola_url      <- tabla_urllength[nrow(tabla_urllength), ]
poligono_url <- tibble(x = c(5, clases_url_10$`Marca de clase`),
                       y = c(0, clases_url_10$fi))
nota_cola_url <- paste0("Clase final ", cola_url$Clase, ": ", miles(cola_url$fi),
                        " URL (", sprintf("%.2f", cola_url$`%`),
                        " %),\nno dibujada por su amplitud")
fig3 <- ggplot() +
  geom_histogram(data = filter(datos, URLLength < 100), aes(x = URLLength),
                 breaks = seq(10, 100, 10), closed = "left",
                 fill = color_neutro, colour = "white") +
  geom_line(data = poligono_url, aes(x, y), colour = color_phishing, linewidth = 0.8) +
  geom_point(data = poligono_url, aes(x, y), colour = color_phishing, size = 2) +
  annotate("label", x = 98, y = max(clases_url_10$fi) * 0.62, label = nota_cola_url,
           hjust = 1, size = 3.3, label.size = 0.25, lineheight = 1) +
  scale_x_continuous(breaks = seq(0, 100, 10), limits = c(0, 100)) +
  scale_y_continuous(labels = label_comma(), expand = expansion(mult = c(0, 0.06))) +
  labs(title = "Histograma y polígono de frecuencias de la longitud de la URL",
       subtitle = "Clases de 10 caracteres (Tabla 8); la línea une las marcas de clase",
       x = "Longitud de la URL (caracteres)", y = "Frecuencia absoluta (URL)",
       caption = fuente_grafico) +
  tema_parcial()
guardar_figura(fig3, "Figura_3_histograma_URLLength")

Figura 3

Histograma y polígono de frecuencias de la longitud de la URL (URLLength)

Nota. Se dibujan las nueve clases de 10 caracteres de la Tabla 8. La clase final, de 100 a 6,097 caracteres, se informa en el recuadro porque su amplitud impide representarla a la misma escala; ningún registro fue eliminado. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

La Figura 3 muestra una distribución con un solo pico y cola hacia la derecha. La clase [20, 30) concentra 129,295 URL (54.83 %) y el 83.47 % de las URL mide menos de 40 caracteres. Solo 4,976 URL (2.11 %) tienen 100 caracteres o más, pero esa cola llega hasta 6,097. En la práctica, las URL muy largas son poco frecuentes en esta base y conviene revisarlas aparte.

4.4.4 Figura 4: histograma de DomainLength

# --- Figura 4: histograma de DomainLength ------------------------------------------
fig4 <- ggplot(datos, aes(x = DomainLength)) +
  geom_histogram(breaks = cortes_dom, closed = "left",
                 fill = color_neutro, colour = "white") +
  scale_x_continuous(breaks = cortes_dom[seq(1, length(cortes_dom), by = 2)]) +
  scale_y_continuous(labels = label_comma(), expand = expansion(mult = c(0, 0.06))) +
  labs(title = "Histograma de la longitud del dominio",
       subtitle = paste0(n_clases_dom, " clases de ", amplitud_dom,
                         " caracteres según la regla de Sturges (Tabla 9)"),
       x = "Longitud del dominio (caracteres)", y = "Frecuencia absoluta (URL)",
       caption = fuente_grafico) +
  tema_parcial()
guardar_figura(fig4, "Figura_4_histograma_DomainLength")

Figura 4

Histograma de la longitud del dominio (DomainLength)

Nota. Clases de la Tabla 9. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

La Figura 4 muestra que la longitud del dominio se concentra entre 10 y 28 caracteres: esas tres clases suman 194,922 URL (82.67 %). La clase modal es [16, 22), con 92,217 URL. La distribución tiene asimetría hacia la derecha y un grupo pequeño separado entre 70 y 82 caracteres (1,851 URL, 0.79 %).

4.4.5 Figura 5: ojiva de DomainLength

# --- Figura 5: ojiva de DomainLength -----------------------------------------------
ojiva_dom <- tibble(x = c(min_dom, tabla_domainlength$Ls),
                    y = c(0, 100 * tabla_domainlength$Hi))
fig5 <- ggplot(ojiva_dom, aes(x, y)) +
  geom_hline(yintercept = 50, linetype = "dashed", colour = "grey45") +
  geom_line(colour = color_legitima, linewidth = 0.9) +
  geom_point(colour = color_legitima, size = 2) +
  annotate("text", x = max(cortes_dom), y = 46, label = "50 % de las URL",
           hjust = 1, size = 3.4, colour = "grey25") +
  scale_x_continuous(breaks = cortes_dom[seq(1, length(cortes_dom), by = 2)]) +
  scale_y_continuous(breaks = seq(0, 100, 20), limits = c(0, 100),
                     labels = function(v) paste0(v, " %")) +
  labs(title = "Ojiva de la longitud del dominio",
       subtitle = "Frecuencia relativa acumulada (Hi) en el límite superior de cada clase (Tabla 9)",
       x = "Longitud del dominio (caracteres)",
       y = "Porcentaje acumulado de URL",
       caption = fuente_grafico) +
  tema_parcial() +
  theme(panel.grid.major.x = element_line(colour = "grey92"))
guardar_figura(fig5, "Figura_5_ojiva_DomainLength")

Figura 5

Ojiva de la longitud del dominio (DomainLength)

Nota. Cada punto es la frecuencia relativa acumulada (Hi) en el límite superior de una clase de la Tabla 9. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

La ojiva de la Figura 5 muestra el porcentaje acumulado de URL hasta cada longitud de dominio. El 22.18 % de los dominios mide menos de 16 caracteres, el 61.29 % menos de 22 y el 84.16 % menos de 28. La curva cruza el 50 % dentro de la clase [16, 22), lo que concuerda con la mediana de 20 caracteres. A partir de 52 caracteres la curva es casi plana: solo el 1.38 % de los dominios mide 52 caracteres o más.

4.4.6 Figura 6: cajas de URLLength por label

# --- Figura 6: diagrama de cajas de URLLength por clase ----------------------------
# Escala logarítmica en el eje vertical: los valores no se transforman ni se
# eliminan; solo cambia la separación del eje para que la cola sea legible.
fig6 <- ggplot(datos_analisis, aes(x = clase, y = URLLength, fill = clase)) +
  geom_boxplot(width = 0.5, outlier.size = 0.6, outlier.alpha = 0.25,
               show.legend = FALSE, colour = color_linea, linewidth = 0.4) +
  scale_fill_manual(values = alpha(colores_clase, 0.85)) +
  scale_y_log10(breaks = c(10, 20, 50, 100, 200, 500, 1000, 2000, 5000),
                labels = label_comma()) +
  labs(title = "Diagrama de cajas de la longitud de la URL según la clasificación",
       subtitle = "Eje vertical en escala logarítmica; los puntos son valores atípicos (criterio de Tukey)",
       x = "Clasificación de la URL (label)",
       y = "Longitud de la URL (caracteres, escala log)",
       caption = fuente_grafico) +
  tema_parcial()
guardar_figura(fig6, "Figura_6_cajas_URLLength_label")

Figura 6

Diagrama de cajas de la longitud de la URL según la clasificación (label)

Nota. El eje vertical está en escala logarítmica para que la caja y los valores extremos sean legibles en la misma figura; los datos no se transformaron. Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

La Figura 6 compara la longitud de la URL entre las dos clases. En las legítimas la caja va de 23 a 29 caracteres, con mediana 26 y máximo 57. En las phishing la caja va de 26 a 48, con mediana 34 y máximo 6,097. Todos los valores de 100 caracteres o más pertenecen a la clase phishing.

4.4.7 Figura 7: barras agrupadas por label

# --- Figura 7: barras agrupadas de variables binarias por clase --------------------
etiquetas_binarias <- c(IsHTTPS = "Usa HTTPS\n(IsHTTPS = 1)",
                        HasTitle = "Tiene título\n(HasTitle = 1)",
                        IsDomainIP = "Dominio es una IP\n(IsDomainIP = 1)")
datos_fig7 <- tabla_binarias_clase |>
  mutate(Variable = factor(etiquetas_binarias[Variable], levels = etiquetas_binarias))
fig7 <- ggplot(datos_fig7, aes(x = Variable, y = `% dentro de la clase`, fill = Clase)) +
  geom_col(position = position_dodge(width = 0.7), width = 0.62) +
  geom_text(aes(label = sprintf("%.2f %%", `% dentro de la clase`)),
            position = position_dodge(width = 0.7), vjust = -0.5, size = 3.5) +
  scale_fill_manual(values = colores_clase, name = "Clasificación (label)") +
  scale_y_continuous(limits = c(0, 100), breaks = seq(0, 100, 20),
                     labels = function(v) paste0(v, " %"),
                     expand = expansion(mult = c(0, 0.1))) +
  labs(title = "Características binarias de las URL según la clasificación",
       subtitle = "Porcentaje calculado dentro de cada clase (legítimas y phishing por separado)",
       x = "Característica observada", y = "Porcentaje de URL de la clase",
       caption = fuente_grafico) +
  tema_parcial()
guardar_figura(fig7, "Figura_7_barras_binarias_label")

Figura 7

Barras agrupadas de tres características binarias según la clasificación (label)

Nota. Porcentajes calculados dentro de cada clase (Tabla 14). Elaboración propia en RStudio con datos de Prasad y Chandra (2024a).

La Figura 7 compara el porcentaje de URL que presenta cada característica dentro de su clase. El 100.00 % de las legítimas usa HTTPS, frente al 49.22 % de las phishing. El 99.88 % de las legítimas tiene título, frente al 67.76 % de las phishing. El uso de una dirección IP como dominio es poco frecuente: 0.63 % en las phishing y 0.00 % en las legítimas. Cerca de la mitad de las URL phishing de la base usa HTTPS, de modo que en estos registros el protocolo no separa por sí solo a las dos clases.

5 Interpretación y conclusiones

5.1 Hallazgos descriptivos

  1. Composición de la base. La variable label registra 134,850 URL legítimas (57.19 %) y 100,945 URL phishing (42.81 %), para un total de 235,795 registros (Tabla 3, Figura 1). La clase modal es la legítima.

  2. Longitud de la URL por clase. La mediana de URLLength es de 26 caracteres en las legítimas y de 34 en las phishing; el RIC es de 6 y 22 caracteres, y el máximo de 57 y 6,097 (Tablas 12 y 13, Figura 6). Las URL phishing de la base son más largas y más dispersas.

  3. Asimetría de la longitud de la URL. En toda la base, la media de URLLength (34.57) supera a la mediana (27), el CV es de 119.50 % y hay 22,493 valores atípicos superiores (9.54 %) (Tabla 10). El 2.11 % de las URL tiene 100 caracteres o más (Tabla 8, Figura 3).

  4. Índice de similitud. Las 134,850 URL legítimas tienen URLSimilarityIndex igual a 100, con desviación estándar 0. En las phishing la mediana es 51.42, el primer cuartil 32.12 y el tercero 67.25 (Tablas 12 y 13); solo 786 URL phishing (0.78 % de su clase) alcanzan el valor 100.

  5. Uso de HTTPS y presencia de título. Dentro de cada clase, el 100.00 % de las legítimas y el 49.22 % de las phishing usan HTTPS; el 99.88 % y el 67.76 %, respectivamente, tienen título (Tabla 14, Figura 7).

  6. Concentración del TLD. De 695 TLD distintos, .com es la moda con el 47.73 % de las URL y los 10 más frecuentes acumulan el 72.87 % (Tabla 5, Figura 2).

  7. Longitud del dominio. La mediana de DomainLength es de 20 caracteres y el 61.29 % de los dominios mide menos de 22 (Tabla 9, Figura 5). Por clase, la mediana es 19 en las legítimas y 22 en las phishing, con desviaciones estándar de 4.82 y 12.20 caracteres (Tablas 12 y 13).

Estas diferencias son observaciones sobre los registros de la base. Una explicación posible de las diferencias de longitud es la forma en que se recolectaron las URL de cada clase, que se describe en las limitaciones; el análisis descriptivo no permite confirmar esa ni otra explicación.

5.2 Conclusiones

  1. La base cumple lo que reporta la ficha de UCI en tamaño, composición por clase y ausencia de valores faltantes: 235,795 registros, 57.19 % legítimos y 42.81 % phishing, sin filas duplicadas exactas.

  2. Las variables de longitud tienen distribuciones asimétricas hacia la derecha, con valores extremos concentrados en la clase phishing. Por eso la mediana y el rango intercuartílico las describen mejor que la media y la desviación estándar: en URLLength la media es 34.57 y la mediana 27.

  3. En las variables analizadas, las URL legítimas de la base forman un grupo muy homogéneo (URLSimilarityIndex constante en 100, HTTPS en el 100.00 %, CV de URLLength de 18.36 %), mientras que las phishing son heterogéneas (CV de URLLength de 133.74 %, HTTPS en el 49.22 %).

5.3 Recomendaciones

  1. Acompañar la base con un diccionario de datos completo. La ficha de UCI no describe las variables ni sus unidades, lista URLSimilarityIndex como entera aunque es decimal y no explica por qué URLLength difiere en un carácter del texto de la URL en el 79.37 % de los registros. Un diccionario evitaría interpretaciones erróneas.

  2. Al resumir las variables de longitud, reportar la mediana y los cuartiles junto con la media, y presentar los valores extremos en una clase o en una escala aparte, como se hizo en la Tabla 8 y en la Figura 6. Así los 4,976 registros de 100 caracteres o más no distorsionan la lectura del resto.

5.4 Limitaciones

La principal limitación es la forma de las URL legítimas. En las 134,850 URL legítimas (el 100 % de la clase) se cumple que URLLength = DomainLength + 7, es decir, todas constan solo del protocolo y el dominio, sin ruta ni parámetros. En las URL phishing esa igualdad se cumple solo en 11,166 registros (11.06 % de la clase); el resto incluye ruta o parámetros. Por lo tanto, parte de las diferencias de longitud entre clases puede reflejar cómo se armó la base y no una característica general de los sitios legítimos.

Un requisito de las instrucciones no se pudo cumplir: el análisis de la variable WebTraffic, que no existe en el archivo oficial. Los demás requisitos se desarrollaron con las variables disponibles. Además, hay 850 registros con URL repetida que se conservaron, y los resultados describen únicamente los 235,795 registros de la base, por lo que no deben generalizarse a todas las URL de internet.

Referencias

Prasad, A., & Chandra, S. (2024a). PhiUSIIL Phishing URL (Website) [Conjunto de datos]. UCI Machine Learning Repository. https://doi.org/10.1016/j.cose.2023.103545

Prasad, A., & Chandra, S. (2024b). PhiUSIIL: A diverse security profile empowered phishing URL detection framework based on similarity index and incremental learning. Computers & Security, 136, Artículo 103545. https://doi.org/10.1016/j.cose.2023.103545

R Core Team. (2026). R: A language and environment for statistical computing (Versión 4.6.1) [Software]. R Foundation for Statistical Computing. https://www.R-project.org/

Wickham, H. (2016). ggplot2: Elegant graphics for data analysis (2.ª ed.). Springer. https://ggplot2.tidyverse.org

Anexo: reproducibilidad

figuras <- list(
  Figura_1_barras_label          = fig1,
  Figura_2_pareto_TLD            = fig2,
  Figura_3_histograma_URLLength  = fig3,
  Figura_4_histograma_DomainLength = fig4,
  Figura_5_ojiva_DomainLength    = fig5,
  Figura_6_cajas_URLLength_label = fig6,
  Figura_7_barras_binarias_label = fig7
)

redondear <- function(t, decimales = 3) {
  mutate(t, across(where(is.double), ~ round(.x, decimales)))
}
tablas_exportar <- list(
  Tabla_01_diccionario             = diccionario,
  Tabla_02_calidad_de_datos        = calidad,
  Tabla_03_TipoI_label             = tabla_label,
  Tabla_04_TipoI_IsHTTPS           = tabla_https,
  Tabla_05_TipoI_TLD               = tabla_tld,
  Tabla_06_TipoI_NoOfSubDomain     = tabla_subdominios,
  Tabla_07_TipoII_NoOfLettersInURL = tabla_letras,
  Tabla_08_TipoIII_URLLength       = tabla_urllength,
  Tabla_09_TipoIII_DomainLength    = tabla_domainlength,
  Tabla_10_medidas_descriptivas    = tabla_medidas,
  Tabla_11_variables_categoricas   = tabla_categoricas,
  Tabla_12_comparacion_centro_posicion = tabla_comparacion_centro,
  Tabla_13_comparacion_dispersion  = tabla_comparacion_dispersion,
  Tabla_14_binarias_por_label      = tabla_binarias_clase,
  Anexo_contraste_UCI              = contraste_uci,
  Anexo_tipos_de_dato              = tabla_tipos,
  Anexo_comprobacion_de_sumas      = comprobacion_tablas
)
for (nombre in names(tablas_exportar)) {
  write_excel_csv(redondear(as_tibble(tablas_exportar[[nombre]])),
                  file.path(dir_tablas, paste0(nombre, ".csv")))
}
for (nombre in names(figuras)) {
  ggsave(file.path(dir_graficos, paste0(nombre, ".png")), figuras[[nombre]],
         width = 8, height = 5.2, dpi = 300, bg = "white")
}
message("[8] Exportadas ", length(tablas_exportar), " tablas a /tablas y ",
        length(figuras), " figuras a /graficos.")

El proyecto se entrega con los siguientes archivos: el script Parcial_Estadistica_Completo.R (carpeta scripts), este documento Informe_Estadistica_Descriptiva.Rmd (carpeta informe), las 17 tablas en formato CSV (carpeta tablas) y las 7 figuras en formato PNG a 300 ppp (carpeta graficos). Para reproducir todo, se abre el archivo Parcial_Estadistica_Descriptiva.Rproj en RStudio y se ejecuta el script completo, que también genera este informe en HTML y en Word.

# Versión de R y de los paquetes con los que se generó el informe
sessionInfo()$R.version$version.string
#> [1] "R version 4.6.1 (2026-06-24 ucrt)"
sapply(c("readr", "dplyr", "tidyr", "ggplot2", "scales", "stringr", "knitr",
         "rmarkdown"), function(p) as.character(packageVersion(p)))
#>     readr     dplyr     tidyr   ggplot2    scales   stringr     knitr rmarkdown 
#>   "2.2.0"   "1.2.1"   "1.3.2"   "4.0.3"   "1.4.0"   "1.6.0"    "1.52"    "2.32"