Almacenamiento en caché
El almacenamiento en caché permite que Spark conserve los datos en todos los cálculos y operaciones. De hecho, esta es una de las técnicas más…
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.
Las transformaciones se pueden dividir en cuatro categorías:
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:
mapfilterflatMapgroupByKeysortByKeycombineByKeyval 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)]
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:
sampleByKeyrandomSplitval 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.
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:
cogroupjoinsubtractByKeyfullOuterJoinleftOuterJoinrightOuterJoinval 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.
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:
partitionByrepartitionzipWithIndexcoalesceval 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.
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 |
Al trabajar con transformaciones en RDDs, es fundamental tener en cuenta los siguientes aspectos:
collect, count o saveAsTextFile) la solicita. Esto permite a Spark optimizar el plan de ejecución antes de procesar los datos.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.persist() o cache() para almacenarlo en memoria y evitar recalcularlo.