Apache Spark

Tipos de transformaciones en un RDD en Apache Spark

8 min lectura José Miguel

En este artículo vamos a hablar de los diferentes tipos de transformaciones que podemos aplicar a un RDD en Apache Spark.

Los RDD son inmutables y cada operación crea un nuevo RDD. Las dos operaciones principales que se pueden realizar en un RDD son transformaciones y acciones. En este caso nos centraremos en las transformaciones, y en específico en los tipos de transformaciones que podemos aplicar en un RDD en Apache Spark.

Las transformaciones cambian los elementos en el RDD. Algunos ejemplos pueden ser dividir el elemento de entrada, filtrar elementos y realizar cálculos de algún tipo. Varias transformaciones se pueden realizar en una secuencia; sin embargo, no se lleva a cabo ninguna ejecución durante la planificación.

Para las transformaciones, Spark las agrega al DAG (Directed Acyclic Graph) de cálculo y, sólo cuando el controlador solicita algunos datos, este DAG realmente se ejecuta. A esto se le llama evaluación perezosa (lazy evaluation).

Las transformaciones crean un nuevo RDD a partir de un RDD existente aplicando la lógica de transformación a cada uno de los elementos del RDD existente.

Tipos de transformaciones en un RDD

Las transformaciones se pueden dividir en cuatro categorías:

Transformaciones generales

Las transformaciones generales son funciones de transformación que manejan la mayoría de los casos de uso de propósito general. Estas aplican la lógica de transformaciones a los RDD existentes y generan un nuevo RDD. Las operaciones comunes de agregación, filtros, etc, se conocen como transformaciones generales. Algunos ejemplos de transformaciones generales son:

  • map
  • filter
  • flatMap
  • groupByKey
  • sortByKey
  • combineByKey

Ejemplo en Scala

val datos = sc.parallelize(List(1, 2, 3, 4, 5))

// map: multiplica cada elemento por 2
val duplicados = datos.map(x => x * 2)
// Resultado: [2, 4, 6, 8, 10]

// filter: obtiene solo los elementos mayores a 3
val filtrados = datos.filter(x => x > 3)
// Resultado: [4, 5]

// flatMap: divide cada línea en palabras
val lineas = sc.parallelize(List("Hola mundo", "Apache Spark"))
val palabras = lineas.flatMap(linea => linea.split(" "))
// Resultado: [Hola, mundo, Apache, Spark]

Para las operaciones sobre pares clave-valor:

val pares = sc.parallelize(List(("a", 1), ("b", 2), ("a", 3), ("b", 4)))

// groupByKey: agrupa los valores por clave
val agrupados = pares.groupByKey()
// Resultado: [(a, [1, 3]), (b, [2, 4])]

// sortByKey: ordena por clave
val ordenados = pares.sortByKey()
// Resultado: [(a, 1), (a, 3), (b, 2), (b, 4)]

// combineByKey: suma los valores por clave
val sumados = pares.combineByKey(
  (v: Int) => v,                         // createCombiner
  (acc: Int, v: Int) => acc + v,          // mergeValue
  (acc1: Int, acc2: Int) => acc1 + acc2   // mergeCombiners
)
// Resultado: [(a, 4), (b, 6)]

Transformaciones matemáticas o estadísticas

Las transformaciones matemáticas o estadísticas son funciones de transformación que manejan alguna funcionalidad estadística, y que generalmente aplican alguna operación matemática o estadística en RDDs existentes, generando un nuevo RDD. El muestreo es un gran ejemplo de esto y se usa a menudo en los programas Spark. Algunos ejemplos de transformaciones matemáticas o estadísticas son:

  • sampleByKey
  • randomSplit

Ejemplo en Scala

val pares = sc.parallelize(List(("a", 1), ("a", 2), ("a", 3), ("b", 4), ("b", 5), ("b", 6)))

// sampleByKey: muestrea el 50% de los elementos con clave "a" y el 100% de los de clave "b"
val fracciones = Map("a" -> 0.5, "b" -> 1.0)
val muestra = pares.sampleByKey(withReplacement = false, fractions = fracciones)

// randomSplit: divide un RDD en dos subconjuntos con proporciones 70% y 30%
val datos = sc.parallelize(1 to 100)
val Array(entrenamiento, prueba) = datos.randomSplit(Array(0.7, 0.3))

Estas transformaciones son especialmente útiles en escenarios de Machine Learning, donde es necesario dividir los datos en conjuntos de entrenamiento y prueba, o cuando se requiere trabajar con muestras representativas de grandes volúmenes de datos.

Transformaciones de conjunto o relacionales

Las transformaciones de conjunto o relacionales son funciones de transformación que manejan transformaciones como uniones de conjuntos de datos y otras funciones algebraicas relacionales como cogrupo. Estas funciones funcionan aplicando la lógica de transformaciones a los RDD existentes y generando un nuevo RDD. Algunos ejemplos de transformaciones de conjunto o relacionales son:

  • cogroup
  • join
  • subtractByKey
  • fullOuterJoin
  • leftOuterJoin
  • rightOuterJoin

Ejemplo en Scala

val rdd1 = sc.parallelize(List(("a", 1), ("b", 2), ("c", 3)))
val rdd2 = sc.parallelize(List(("a", 10), ("b", 20), ("d", 40)))

// join: inner join por clave
val joined = rdd1.join(rdd2)
// Resultado: [(a, (1, 10)), (b, (2, 20))]

// leftOuterJoin: mantiene todas las claves del RDD izquierdo
val leftJoined = rdd1.leftOuterJoin(rdd2)
// Resultado: [(a, (1, Some(10))), (b, (2, Some(20))), (c, (3, None))]

// fullOuterJoin: mantiene todas las claves de ambos RDDs
val fullJoined = rdd1.fullOuterJoin(rdd2)
// Resultado: [(a, (Some(1), Some(10))), (b, (Some(2), Some(20))), (c, (Some(3), None)), (d, (None, Some(40)))]

// subtractByKey: elimina del rdd1 las claves que existen en rdd2
val restados = rdd1.subtractByKey(rdd2)
// Resultado: [(c, 3)]

// cogroup: agrupa los valores de ambos RDDs por clave
val cogrouped = rdd1.cogroup(rdd2)
// Resultado: [(a, ([1], [10])), (b, ([2], [20])), (c, ([3], [])), (d, ([], [40]))]

Estas transformaciones son análogas a las operaciones JOIN en SQL y resultan fundamentales cuando se trabaja con múltiples conjuntos de datos que comparten una clave en común.

Transformaciones basadas en la estructura de los datos

Las transformaciones basadas en la estructura de los datos son funciones de transformación que operan en las estructuras de datos subyacentes del RDD, hablamos de las particiones en el RDD. En estas funciones usted puede trabajar directamente en las particiones sin tocar directamente los elementos/datos dentro del RDD. Estos son esenciales en cualquier programa Spark más allá de los programas simples donde necesita más control de las particiones y distribución de particiones en el clúster. Por lo general, las mejoras de rendimiento se pueden realizar redistribuyendo las particiones de datos de acuerdo con el estado del clúster y el tamaño de los datos, y los requisitos exactos del caso de uso. Algunos ejemplos de transformaciones basadas en la estructura de los datos son:

  • partitionBy
  • repartition
  • zipWithIndex
  • coalesce

Ejemplo en Scala

val datos = sc.parallelize(1 to 100, 10) // RDD con 10 particiones

// repartition: redistribuye los datos en 4 particiones (implica shuffle)
val reparticionado = datos.repartition(4)

// coalesce: reduce a 2 particiones sin shuffle completo (más eficiente que repartition)
val reducido = datos.coalesce(2)

// zipWithIndex: asigna un índice único a cada elemento
val conIndice = datos.zipWithIndex()
// Resultado: [(1, 0), (2, 1), (3, 2), ...]

// partitionBy: reparticiona un RDD de pares clave-valor usando un particionador específico
import org.apache.spark.HashPartitioner
val pares = datos.map(x => (x % 3, x))
val particionado = pares.partitionBy(new HashPartitioner(3))

Es importante entender la diferencia entre repartition y coalesce: mientras que repartition puede tanto aumentar como reducir el número de particiones (realizando un shuffle completo de los datos), coalesce solo puede reducir el número de particiones y lo hace de manera más eficiente al evitar un shuffle completo. Como regla general, use coalesce cuando desee reducir particiones y repartition cuando necesite aumentarlas.

Tabla de funciones de transformación

La siguiente es una lista de algunas funciones de transformación disponibles en Spark:

Función Descripción Categoría
map Aplica una función a cada elemento del RDD y devuelve un nuevo RDD con los resultados General
filter Devuelve un nuevo RDD que contiene solo los elementos que cumplen una condición General
flatMap Similar a map, pero cada elemento de entrada puede mapearse a cero o más elementos de salida General
groupByKey Agrupa los valores de cada clave en un iterable General
sortByKey Ordena los elementos de un RDD de pares por clave General
combineByKey Combina los valores de cada clave usando funciones de combinación personalizadas General
reduceByKey Combina los valores de cada clave usando una función de reducción asociativa General
sampleByKey Devuelve un subconjunto del RDD muestreado por clave según fracciones especificadas Matemática/Estadística
randomSplit Divide un RDD en múltiples subconjuntos según pesos proporcionados Matemática/Estadística
sample Devuelve una muestra aleatoria del RDD con o sin reemplazo Matemática/Estadística
join Realiza un inner join entre dos RDDs de pares por clave Conjunto/Relacional
leftOuterJoin Realiza un left outer join, manteniendo todas las claves del RDD izquierdo Conjunto/Relacional
rightOuterJoin Realiza un right outer join, manteniendo todas las claves del RDD derecho Conjunto/Relacional
fullOuterJoin Realiza un full outer join, manteniendo todas las claves de ambos RDDs Conjunto/Relacional
subtractByKey Elimina del RDD los elementos cuyas claves existen en el otro RDD Conjunto/Relacional
cogroup Agrupa los datos de ambos RDDs por clave en tuplas de iterables Conjunto/Relacional
partitionBy Reparticiona un RDD de pares usando un particionador específico Estructura de datos
repartition Redistribuye los datos en un número específico de particiones (con shuffle) Estructura de datos
coalesce Reduce el número de particiones de forma eficiente (sin shuffle completo) Estructura de datos
zipWithIndex Asigna un índice secuencial único a cada elemento del RDD Estructura de datos

Consideraciones importantes

Al trabajar con transformaciones en RDDs, es fundamental tener en cuenta los siguientes aspectos:

  • Lazy evaluation: Ninguna transformación se ejecuta hasta que una acción (como collect, count o saveAsTextFile) la solicita. Esto permite a Spark optimizar el plan de ejecución antes de procesar los datos.
  • Inmutabilidad: Cada transformación produce un nuevo RDD; el RDD original permanece sin cambios. Esto facilita la tolerancia a fallos y la consistencia de los datos.
  • Narrow vs Wide transformations: Las transformaciones como map y filter son narrow (cada partición de salida depende de una sola partición de entrada), mientras que groupByKey y join son wide (requieren un shuffle de datos entre particiones). Las transformaciones wide son más costosas en términos de rendimiento.
  • Persistencia: Si un RDD transformado se reutiliza múltiples veces, considere usar persist() o cache() para almacenarlo en memoria y evitar recalcularlo.
José Miguel Moya Curbelo
José Miguel Moya Curbelo
Senior Data Engineer & Big Data Instructor

MSc Applied Mathematics · AWS Cloud Practitioner · SCRUM Master. Especializado en arquitecturas de datos de alto rendimiento con Apache Spark, Snowflake, Python y Scala.

Conectar en LinkedIn

Artículos Relacionados

Deja un comentario

Tu dirección de correo electrónico no será publicada.