# Dabbling with Spark Essentials Again

Apache Spark stands out as a pivotal tool for navigating the complexities of Big Data analysis. This article embarks on a comprehensive journey through the core of Spark, from unraveling the intricacies of Big Data and its foundational concepts to mastering sophisticated data processing techniques with PySpark. Whether you're a beginner eager to dip your toes into the vast ocean of data analysis or a seasoned analyst aiming to refine your skills with Spark's advanced functionalities, this guide offers a structured pathway to enhance your proficiency in transforming raw data into insightful, actionable knowledge. I delve deep into essential concepts such as lazy evaluation, explain plan, and manipulation of Spark RDDs.

# **SparkContext**

The entry point to programming Spark with the RDD interface. It is responsible for managing and distributing data across the Spark cluster and creating RDDs. It can load data from various sources like HDFS (Hadoop Distributed File System), local file systems, and external databases. Through `SparkContext`, you can set various Spark properties and configurations that control aspects like memory usage, core utilization, and the behavior of Spark’s scheduler.

```python
from pyspark import SparkContext
sc = SparkContext(master="local", appName="My App")
```

## Info

Retrieves the SparkContext version, the Python version, and the master URL, respectively.

```python
print(sc.version, sc.pythonVer, sc.master)
```

## **SparkContext vs SparkSession**

While SparkContext is used for accessing Spark features through RDDs, SparkSession provides a single point of entry for DataFrames, streaming, or Hive features including HiveContext, SQLContext or Streaming Context. Also, with the introduction of Spark 2.0, SparkSession was introduced as a new entry point for Spark applications.

```python
from pyspark.sql import SparkSession
spark = SparkSession.builder.appName("My App").getOrCreate()
```

# Resilient Distributed Datasets **- Under the Hood**

<iframe src="https://giphy.com/embed/xTiIzHJyaDjBJzWKnS" width="480" height="208" class="giphy-embed"></iframe>

In the last article, I briefly explained about [the lineage of transformations that promotes the fault tolerant nature of Resilient Distributed Datasets](https://vaishnave.page/dabbling-with-spark-essentials#heading-directed-acyclic-graph-dag:~:text=as%20they%20track%20the%20lineage%20of%20transformations%20applied%20to%20them%2C%20allowing%20for%20re%2Dcomputation%20in%20case%20of%20node%20failures.%20(Analogous%20to%20Git%20version%20tracking)). Let's look at that in detail now:

## **Lazy Evaluation**

When you apply a transformation like `sort` to your data in Spark, it's crucial to understand that Spark operates under a principle known as **Lazy Evaluation**. This means that when you call a transformation, Spark doesn't immediately manipulate your data. Instead, what happens is that Spark queues up a series of transformations, building a plan for how it will eventually execute these operations across the cluster when necessary.

Lazy evaluation in Spark ensures that transformations like `sort` don't trigger any immediate action on the data. The transformation is registered as a part of the execution plan, but *Spark waits to execute any operations until an action* (such as `collect`, `count`, or `saveAsTextFile`) i*s called*.

<iframe src="https://giphy.com/embed/3o84sw9CmwYpAnRRni" width="480" height="208" class="giphy-embed"></iframe>

This delay in execution allows Spark to map out a comprehensive strategy for distributing the data processing workload, enhancing the system's ability to manage resources and execute tasks in an optimized manner.

## **Explain Plan**

To inspect the execution plan that Spark has prepared, including the sequence of transformations and how they will be applied, you can use the `explain` method on any DataFrame object. This method reveals the DataFrame's lineage, illustrating how Spark intends to execute the given query. This insight into the execution plan is particularly valuable for optimizing performance and understanding the sequence of operations Spark will undertake to process your data.

## **Lineage Graph**

Now, the question arises- What actually are these "lineages"?

RDD lineage is essentially a record of what operations were performed on the data from the time it was loaded into memory up to the present. It’s a directed acyclic graph (DAG) of the entire parent RDDs of an RDD. This graph consists of edges and nodes, where the **nodes represent the RDDs** and the **edges represent the transformations** that lead from one RDD to the next.

# Creating and Using RDDs

## **Parallelized collection**

Distributes a local Python collection to form an RDD. Useful for parallelizing existing Python collections into Spark RDDs.

```python
parallelized_data = sc.parallelize([1, 2, 3, 4, 5])
```

## **From external tables**

Used for creating distributed collections of unstructured data from a text file.

```python
rdd_from_text_file = sc.textFile("path/to/textfile")
```

## **Partitioning**

Partitioning in RDDs refers to the division of the dataset into smaller, logical portions that can be distributed across multiple nodes in a Spark cluster. This concept is fundamental to distributed computing in Spark, enabling parallel processing, which significantly enhances performance for large datasets. More on this topic will be covered in an upcoming article.

```python
rdd_with_partitions = rdd_from_file.repartition(10)
print(rdd_with_partitions.getNumPartitions())
```

Alters the number of partitions in an RDD to distribute data more evenly across the cluster.

## **Converting an RDD to a DataFrame**

The conversion of an RDD to a DataFrame in PySpark can be accomplished through multiple methods, involving the definition of the schema in different ways.

* `StructType`
    

```python
from pyspark.sql.types import StructType, StructField, StringType, IntegerType
schema = StructType([
    StructField("name", StringType(), True),
    StructField("age", IntegerType(), True),
    StructField("city", StringType(), True)
])
rdd = sc.parallelize([("Ankit", 30, "Bangalore"), ("Anitha", 25, "Chennai"), ("Aditya", 35, "Mumbai")])
df = spark.createDataFrame(rdd, schema)
```

* `pyspark.sql.Row`
    

```python
from pyspark.sql import Row
rdd = sc.parallelize([Row(name="Ankit", age=30, city="Bangalore"), Row(name="Anitha", age=25, city="Chennai"), Row(name="Aditya", age=35, city="Mumbai")])
df = spark.createDataFrame(rdd)
```

## **Converting CSV to DataFrame**

```python
df_from_csv = spark.read.csv("path/to/file.csv", header=True, inferSchema=True)
```

Directly reads a CSV file into a DataFrame, inferring the schema.

## **Inspecting Data in DataFrame**

```python
df_from_csv.printSchema()
df_from_csv.describe().show()
```

Displays the schema of the DataFrame and shows summary statistics.

## **DataFrame Visualization**

```python
pandas_df = df_from_csv.toPandas()
```

Converts a Spark DataFrame to a Pandas DataFrame for visualization.

## **SQL queries**

```python
df_from_csv.createOrReplaceTempView("people")
spark.sql("SELECT * FROM people WHERE age > 30").show()
```

[Executes SQL queries directly on DataFrames that have been registered as a temporary view.](https://vaishnave.page/dabbling-with-spark-essentials#heading-directed-acyclic-graph-dag:~:text=createOrReplaceTempView()%3A%20This,in%20Spark%20SQL.)

# Types of RDDs

RDDs are categorized based on their characteristics, which can include their partitioning strategy or the data types they contain. Understanding these categories is essential for optimizing the performance of Spark applications, as *each type has its implications for how data is processed and distributed across the cluster*. Here's a deeper look into these categorizations:

## **Based on Partitioning**

### ParallelCollection RDDs

These RDDs are created from existing Scala or Python collections. When you parallelize a collection to create an RDD using the `parallelize()` method, Spark *distributes the elements of the collection across the cluster*. The distribution is determined by the number of partitions you specify or by the default parallelism level of the cluster. ParallelCollection RDDs are useful for parallelizing small datasets or for testing and prototyping with Spark.

### MapPartitions RDDs

MapPartitions RDDs result from transformations that apply a function to *every partition of the parent RDD, rather than to individual elements*. Transformations like `mapPartitions()` and `mapPartitionsWithIndex()` create MapPartitions RDDs. This approach can be more efficient than applying a function to each element, especially when initialising an expensive operation (like opening a database connection) that is better done once per partition rather than once per element.

### Shuffled RDDs

Shuffled RDDs are the result of transformations that cause data to be redistributed across the cluster, such as `repartition()` or by key-based transformations like `reduceByKey()`, `groupBy()`, and `join()`. These operations involve *shuffling data across the partitions to regroup it* according to the transformation's requirements. Shuffles are expensive operations in terms of network and disk I/O, so understanding when your operations result in Shuffled RDDs can help in optimizing your Spark jobs.

## **Based on Data Types**

RDDs can also be distinguished by the types of data they contain. This classification is more about the content of the RDD rather than its structural characteristics.

### Generic RDDs (like Python RDD)

These are RDDs that contain any type of object, such as integers, strings, or custom objects. They are the most flexible type of RDD, as Spark does not impose any constraints on the data type. However, operations on these RDDs may not be as optimized as those on more specific types of RDDs.

### Key-Value Pair RDDs (Pair RDDs)

Pair RDDs contain tuples as elements, where each tuple consists of a key and a value. This structure is especially useful for operations that need to process data based on keys, such as aggregating or grouping data by key. I will also go into detail about this specific type of RDD later.

Understanding the different types of RDDs and their characteristics is crucial for writing efficient Spark applications. By choosing the appropriate type of RDD and transformations, developers can optimize data distribution and processing strategies, improving the performance and scalability of their Spark applications.

# Transformations on RDDs

Transformations create a new RDD from an existing one. Here are some key transformations:

## map()

Applies a function to each element of the RDD, returning a new RDD with the results.

```python
rdd = sc.parallelize([1, 2, 3, 4])
mapped_rdd = rdd.map(lambda x: x * 2)
# Resulting RDD: [2, 4, 6, 8]
```

## filter()

Returns a new RDD containing only the elements that satisfy a condition.

```python
filtered_rdd = rdd.filter(lambda x: x % 2 == 0)
# Resulting RDD: [2, 4]
```

This operation does not modify the original RDD but creates a new one with the filtered results.

## flatMap()

Similar to `map`, but each input item can be mapped to 0 or more output items.

```python
sentences_rdd = sc.parallelize(["hello world", "how are you"])
flattened_rdd = sentences_rdd.flatMap(lambda x: x.split(" "))
# Resulting RDD: ["hello", "world", "how", "are", "you"]
```

# **Actions on RDDs**

Actions trigger the execution of the transformations and return results.

## collect()

Gathers the entire RDD's data to the driver program. Suitable for small datasets.

```python
all_elements = mapped_rdd.collect()
# Result: [2, 4, 6, 8]
```

## take(N)

Returns an array with the first N elements of the RDD.

```python
first_two = mapped_rdd.take(2)
# Result: [2, 4]
```

## first()

Retrieves the first element of the RDD.

```python
first_element = rdd.first()
# Result: 1
```

## count()

Counts the number of elements in the RDD.

```python
total = rdd.count()
# Result: 4
```

## reduce()

Aggregates the elements of the RDD using a function, bringing the result to the driver.

```python
sum = rdd.reduce(lambda x, y: x + y)
# Result: 10
sum_mapped = mapped_rdd.reduce(lambda x, y: x + y)
# Result: 20
product = rdd.reduce(lambda x, y: x * y)
# Result: 24
```

## saveAsTextFile()

Saves the RDD to the filesystem as a text file.

```python
rdd.saveAsTextFile("path/to/output")
```

## coalesce()

Reduces the number of partitions in the RDD, useful for reducing the dataset size before collecting it on the driver. This method is particularly useful for increasing the efficiency of operations that may benefit from fewer partitions, such as reducing the number of files generated by actions like `saveAsTextFile()` or optimizing data for export to a single file.

```python
# This operation prepares the RDD for efficient saving or collecting 
# but does not alter its content.

coalesced_rdd = rdd.coalesce(1)

#Result - reduces the number of partitions in a dataset to 1
```

Each of these transformations and actions plays a vital role in manipulating and retrieving data from RDDs in Spark, enabling efficient distributed data processing across a cluster.

# Key-Value Pair RDDs in Detail

Spark includes dedicated transformations and actions designed for use with Pair RDDs, and I'll cover these in the following discussion.

## Creating Pair RDDs

### **Tuples**

You can create Pair RDDs by mapping an existing RDD to a format where each element is a tuple representing a key-value pair.

```python
rdd = sc.parallelize(['apple', 'banana', 'apple', 'orange', 'banana', 'apple'])
pair_rdd = rdd.map(lambda fruit: (fruit, 1))
```

### **Regular RDDs**

Regular RDDs can be transformed into Pair RDDs using transformations that produce key-value pairs, demonstrating the flexibility in handling structured data.

```python
pair_rdd_from_regular = sc.parallelize([("apple", 1), ("banana", 2), ("apple", 3)])
```

## Transformations

Transformations on Pair RDDs allow for sophisticated aggregation and sorting based on keys.

### **reduceByKey()**

Aggregates values for each key using a specified reduce function.

```python
reduced_rdd = pair_rdd.reduceByKey(lambda a, b: a + b)
# Output example: [('banana', 2), ('apple', 3), ('orange', 1)]
```

### **sortByKey()**

Sorts the RDD by keys.

```python
sorted_rdd = reduced_rdd.sortByKey()
# Output example: [('apple', 3), ('banana', 2), ('orange', 1)]
```

### **groupByKey()**

Groups values with the same key.

```python
grouped_rdd = pair_rdd.groupByKey().mapValues(list)
# Output example: [('banana', [1, 1]), ('apple', [1, 1, 1]), ('orange', [1])]
```

This operation groups all the values under each key into a list. It demonstrates the distribution of individual occurrences before aggregation.

### **join()**

Joins two RDDs based on their keys.

```python
price_rdd = sc.parallelize([("apple", 0.5), ("banana", 0.75), ("orange", 0.8)])
joined_rdd = reduced_rdd.join(price_rdd)
```

## Actions

Actions on Pair RDDs trigger computation and return results to the driver program or store them in external storage.

### **countByKey()**

Counts the number of elements for each key.

```python
counts_by_key = pair_rdd.countByKey()
# Output example: defaultdict(<class 'int'>, {'apple': 3, 'banana': 2, 'orange': 1})
```

This result is a Python dictionary-like object (specifically a `defaultdict` of type `<class 'int'>`) that shows how many times each fruit appears in the original dataset.

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">Notice how we got a very similar result in <code>reduceByKey()</code> on using that specific lambda function. Both operations seem to aggregate data based on keys. But, what is the difference apart from the <code>defaultdict</code> type result?</div>
</div>

`reduceByKey()`: Performs a shuffle across the nodes to aggregate values by key. It's more flexible, allowing for complex aggregation functions beyond simple counting. It's suitable when your RDD contains complex data manipulations or when the dataset is not initially in the form of `(key, 1)` pairs.

`countByKey()`: Provides a more direct approach for counting keys when the RDD is already structured as `(key, 1)` pairs, making it efficient for simple counting tasks. However, it collects the results to the driver, which could be problematic for very large datasets.

Now you understand which option to use and when.

### **collectAsMap()**

Collects the result as a dictionary to bring back to the driver program.

```python
result_map = reduced_rdd.collectAsMap()
# Output example: {'banana': 2, 'apple': 3, 'orange': 1}
```

These operations on Pair RDDs illustrate Spark's powerful capabilities for distributed data processing, especially when working with data that naturally fits into key-value pairs.

# Conclusion

In conclusion, this article has delved deeply into the foundational aspects of Apache Spark, highlighting the pivotal role of SparkContext as the gateway to leveraging Spark's powerful capabilities. Additionally, we've explored the intricacies of Resilient Distributed Datasets (RDDs), uncovering how they serve as the backbone for distributed data processing within Spark. As we move forward, anticipate further exploration into the realms of Spark optimization and memory management. I'm excited to have you join me on this learning journey!
