Data Manipulation at Scale
Pandas, Dask, Vaex & Polars for In-Memory Processing
Beyond Small Datasets
When working with datasets that are too large for basic Python data structures but still fit in your machine's memory (typically 10GB-100GB), specialized data manipulation frameworks become essential. This lesson covers Pandas (the industry standard), Dask (parallel Pandas), Vaex (out-of-core processing), and Polars (Rust-based speed). Each excels in different scenarios, and choosing the right tool dramatically impacts performance and developer productivity.
The Data Manipulation Landscape
Each framework offers different trade-offs between ease of use, performance, and functionality:
Pandas
Focus: General-purpose, in-memory analytics
Key concept: DataFrame as primary abstraction
When to use: Default choice, rich ecosystem, datasets < 10GB
Dask
Focus: Parallel, distributed Pandas
Key concept: Lazy evaluation with task graphs
When to use: Pandas code on larger datasets, parallel processing
Vaex
Focus: Out-of-core, lazy evaluation
Key concept: Memory-mapped files, virtual columns
When to use: Billion-row datasets, exploratory analysis
Polars
Focus: High-performance, multi-threaded
Key concept: Rust-based, Arrow-native
When to use: Speed-critical pipelines, modern alternative
Pandas: The Industry Standard
Pandas is the de facto standard for data manipulation in Python. Built on NumPy, it provides high-performance, easy-to-use data structures and analysis tools. If you're doing data work in Python, you're almost certainly using Pandas.
Core Capabilities
1. Loading Data & Basic Operations
import pandas as pd
# Load from various sources
df = pd.read_csv('sales_data.csv')
df = pd.read_parquet('sales_data.parquet') # Much faster!
df = pd.read_sql('SELECT * FROM sales', connection)
# Basic info
print(df.shape) # (1000000, 10) - 1M rows, 10 columns
print(df.dtypes) # Column data types
print(df.info()) # Memory usage, non-null counts
print(df.describe()) # Statistical summary
# Quick preview
print(df.head()) # First 5 rows
print(df.tail(10)) # Last 10 rows
print(df.sample(5)) # 5 random rowsExpected Output:
# Example output:
date customer_id product_id quantity price
0 2024-01-01 1001 501 2 29.99
1 2024-01-01 1002 502 1 49.99
2 2024-01-02 1001 501 3 29.99
...2. Selection & Filtering
.loc[] for labels, .iloc[] for positions, and boolean indexing for filtering.# Column selection
df['customer_id'] # Single column (Series)
df[['customer_id', 'price']] # Multiple columns (DataFrame)
# Row selection
df.loc[0] # By label (first row)
df.iloc[0] # By position (first row)
df.loc[0:5] # Slice by label (inclusive)
df.iloc[0:5] # Slice by position (exclusive)
# Boolean filtering
high_value = df[df['price'] > 100]
recent = df[df['date'] > '2024-01-01']
# Multiple conditions
filtered = df[
(df['price'] > 50) &
(df['quantity'] > 1) &
(df['customer_id'].isin([1001, 1002, 1003]))
]
# Query method (more readable for complex filters)
filtered = df.query('price > 50 and quantity > 1')Expected Output:
# Output: print(len(filtered)) # 45231 rows match criteria
3. Grouping & Aggregation
# Simple aggregation
total_sales = df['price'].sum()
avg_quantity = df['quantity'].mean()
# Group by single column
by_customer = df.groupby('customer_id').agg({
'price': 'sum',
'quantity': 'mean',
'product_id': 'count'
})
# Group by multiple columns
by_customer_product = df.groupby(['customer_id', 'product_id']).agg({
'price': ['sum', 'mean', 'std'],
'quantity': ['sum', 'count']
})
# Custom aggregation functions
def calculate_revenue(group):
return (group['price'] * group['quantity']).sum()
revenue_by_customer = df.groupby('customer_id').apply(calculate_revenue)
# Multiple aggregations with rename
summary = df.groupby('customer_id').agg(
total_revenue=('price', 'sum'),
avg_order_value=('price', 'mean'),
order_count=('customer_id', 'count'),
unique_products=('product_id', 'nunique')
)Expected Output:
# Output: print(summary) # total_revenue avg_order_value order_count unique_products # customer_id # 1001 12458.93 41.53 300 25 # 1002 8932.12 35.73 250 18 # ...
4. Data Transformation
# Create new columns
df['revenue'] = df['price'] * df['quantity']
df['discount_applied'] = df['price'] < 30
# Apply functions
df['price_category'] = df['price'].apply(
lambda x: 'High' if x > 100 else 'Medium' if x > 50 else 'Low'
)
# Vectorized operations (much faster!)
df['total_cost'] = df['price'] * df['quantity'] * 1.1 # Add 10% tax
# Map values
category_map = {501: 'Electronics', 502: 'Clothing', 503: 'Food'}
df['category'] = df['product_id'].map(category_map)
# Merge datasets
customers = pd.read_csv('customers.csv')
merged = df.merge(
customers,
on='customer_id',
how='left' # left, right, inner, outer
)
# Pivot tables (wide format)
pivot = df.pivot_table(
values='revenue',
index='date',
columns='product_id',
aggfunc='sum',
fill_value=0
)
# Melt (long format)
long_format = pivot.reset_index().melt(
id_vars=['date'],
var_name='product_id',
value_name='revenue'
)Expected Output:
# Output: print(pivot.head()) # product_id 501 502 503 # date # 2024-01-01 1234.5 2345.6 3456.7 # 2024-01-02 1567.8 2678.9 3789.0 # ...
When to Use Pandas
✓ Advantages
- Industry standard with massive ecosystem
- Excellent documentation and community
- Rich functionality for data manipulation
- Integrates with virtually everything
- Easy to learn, intuitive API
- Great for datasets < 10GB
✗ Limitations
- Single-threaded (doesn't use all cores)
- Requires entire dataset in memory
- Slow on very large datasets (> 10GB)
- Memory inefficient for some operations
- Not optimized for streaming data
- Inconsistent API in some areas
Dask: Parallel Pandas
Dask scales Pandas workflows to larger-than-memory datasets and multi-core processing. It uses lazy evaluation and task graphs to parallelize operations, and its API mirrors Pandas, making migration straightforward.
Core Concepts
1. Lazy Evaluation & Task Graphs
.compute() to execute the graph in parallel. This allows Dask to optimize the entire computation pipeline.import dask.dataframe as dd
# Load data (lazy - doesn't load immediately)
ddf = dd.read_csv('large_sales_*.csv') # Multiple files
ddf = dd.read_parquet('sales_data.parquet')
# All operations are lazy (no computation yet)
filtered = ddf[ddf['price'] > 50]
grouped = filtered.groupby('customer_id')['price'].sum()
result = grouped / 1000 # Still no computation!
# Trigger computation
final_result = result.compute() # Now it executes in parallel
# Check task graph
print(result.visualize()) # Shows computation plan
# Partitions (how Dask splits data)
print(ddf.npartitions) # 20 - data split into 20 chunks
print(ddf.divisions) # Partition boundaries
# Each partition is a Pandas DataFrame
# Dask processes partitions in parallel across coresExpected Output:
# Example output after .compute(): # customer_id # 1001 12.458 # 1002 8.932 # 1003 15.234 # ...
2. Parallel Operations
.persist() to keep results in memory for reuse.from dask.distributed import Client
# Start local cluster (uses all cores)
client = Client() # Dashboard at http://localhost:8787
# Load large dataset
ddf = dd.read_parquet('sales_100gb.parquet')
# Complex pipeline (all operations are lazy)
result = (
ddf
.query('price > 50 and quantity > 1')
.groupby('customer_id')
.agg({
'price': ['sum', 'mean'],
'quantity': 'sum',
'product_id': 'nunique'
})
.compute() # Execute in parallel
)
# Persist in memory for reuse
ddf_filtered = ddf[ddf['price'] > 50].persist()
# Now operations on ddf_filtered are fast (already in memory)
high_value_customers = ddf_filtered.groupby('customer_id')['price'].sum()
product_diversity = ddf_filtered.groupby('customer_id')['product_id'].nunique()
# Both use the same persisted data
result1 = high_value_customers.compute()
result2 = product_diversity.compute()
# Custom functions with map_partitions
def custom_analysis(partition):
"""Runs on each partition (Pandas DataFrame)"""
# Do whatever you want with Pandas
return partition[partition['price'] > partition['price'].mean()]
result = ddf.map_partitions(custom_analysis).compute()Expected Output:
# Output:
print(f"Processed {len(ddf)} rows using {client.ncores()} cores")
# Processed 1000000000 rows using 16 cores3. Converting Pandas Code
pd with dd and adding.compute() calls.# PANDAS VERSION (works for small data)
import pandas as pd
df = pd.read_csv('data.csv')
result = (
df[df['price'] > 50]
.groupby('customer_id')['price']
.mean()
)
# DASK VERSION (works for large data) - minimal changes!
import dask.dataframe as dd
ddf = dd.read_csv('data.csv') # Changed pd to dd
result = (
ddf[ddf['price'] > 50]
.groupby('customer_id')['price']
.mean()
.compute() # Added .compute()
)
# What Dask doesn't support well:
# - Operations requiring full dataset sorting
# - Complex window functions
# - Some merge operations with shuffling
# - Operations that break partitioning
# For these, might need to use .compute() to go back to Pandas
difficult_operation = ddf.compute() # Convert to Pandas
result = difficult_operation.sort_values('complex_column')
# Then convert back to Dask if needed
ddf_result = dd.from_pandas(result, npartitions=20)Expected Output:
# Output (both versions produce same result): print(result) # customer_id # 1001 42.15 # 1002 38.92 # 1003 51.23 # ...
When to Use Dask
✓ Advantages
- Uses all CPU cores automatically
- Handles larger-than-memory datasets
- Nearly identical API to Pandas
- Great diagnostic dashboard
- Can scale to clusters (not just local)
- Lazy evaluation optimizes pipelines
✗ Limitations
- Slower than Pandas for small data
- Not all Pandas operations supported
- Adds complexity (task graphs, partitions)
- Debugging is harder (distributed)
- Overhead for simple operations
- Requires understanding of partitioning
Vaex: Billion-Row Exploration
Vaex specializes in fast exploration of massive datasets through memory mapping and lazy evaluation. It can handle billion-row datasets on a laptop by processing data out-of-core, reading only what's needed from disk.
Core Concepts
1. Memory Mapping & Out-of-Core
import vaex
# Open dataset (instant, no loading!)
df = vaex.open('sales_1billion_rows.hdf5')
df = vaex.open('sales_data.parquet')
df = vaex.open('data/*.csv') # Multiple files
# Only show what's needed (still not loading all data)
print(df.head())
print(df.tail())
print(df[1000:1010]) # Read only these rows from disk
# Statistics are computed on-demand
print(df.price.mean()) # Fast! Uses efficient algorithms
print(df.price.std()) # Doesn't load all data
print(df.customer_id.unique()) # Scans data, but efficiently
# Convert to Pandas (loads into memory - be careful!)
small_subset = df[df['price'] > 1000]
pandas_df = small_subset.to_pandas_df() # Now it's in RAMExpected Output:
# Output: print(df) # # customer_id product_id price quantity # 0 1001 501 29.99 2 # 1 1002 502 49.99 1 # ... # [1,000,000,000 rows x 10 columns] # Opened in 0.001 seconds!
2. Virtual Columns
import vaex
df = vaex.open('sales_data.hdf5')
# Create virtual columns (no memory used!)
df['revenue'] = df.price * df.quantity
df['is_high_value'] = df.price > 100
df['price_category'] = df.price.apply(
lambda x: 'High' if x > 100 else 'Medium' if x > 50 else 'Low'
)
# Virtual columns computed on-demand
print(df.revenue.mean()) # Computes revenue on-the-fly
print(df.revenue.sum())
# Filter with virtual columns
high_revenue = df[df.revenue > 1000]
# Export to materialize virtual columns
df.export('sales_with_revenue.hdf5') # Now revenue is a real column
# Expressions are also virtual
df['complex_calc'] = (df.price * df.quantity * 1.1) + df.tax
# Chain operations (all virtual, no memory)
df['normalized_price'] = (df.price - df.price.mean()) / df.price.std()
df['price_zscore'] = df.normalized_priceExpected Output:
# Output: print(df) # Shows all columns, including virtual ones # Shows original columns + revenue, is_high_value, price_category, etc. # Memory usage: unchanged (virtual columns use 0 bytes)
3. Fast Aggregations & Grouping
import vaex
df = vaex.open('sales_1billion_rows.hdf5')
# Simple aggregations (very fast!)
print(df.price.mean())
print(df.price.sum())
print(df.price.std())
print(df.customer_id.nunique())
# Groupby (optimized for large data)
grouped = df.groupby('customer_id').agg({
'price': 'mean',
'quantity': 'sum',
'product_id': vaex.agg.count()
})
# Multiple aggregations
grouped = df.groupby('customer_id', agg={
'total_revenue': vaex.agg.sum('price'),
'avg_price': vaex.agg.mean('price'),
'order_count': vaex.agg.count(),
'unique_products': vaex.agg.nunique('product_id')
})
# Binning (histograms) - super fast
counts = df.count(binby='price', limits=[0, 1000], shape=100)
# 2D histograms
counts_2d = df.count(
binby=[df.price, df.quantity],
limits=[[0, 1000], [0, 100]],
shape=[100, 50]
)Expected Output:
# Output (computed efficiently on 1B rows): print(grouped) # customer_id total_revenue avg_price order_count unique_products # 0 1001 12458.93 41.53 300 25 # 1 1002 8932.12 35.73 250 18 # ... # Time taken: ~5 seconds for 1 billion rows!
When to Use Vaex
✓ Advantages
- Handles billion+ row datasets easily
- Instant opening (memory mapped)
- Extremely fast aggregations
- Virtual columns use no memory
- Great for exploratory analysis
- Works on datasets larger than RAM
✗ Limitations
- Smaller ecosystem than Pandas
- Not all Pandas operations available
- Best with HDF5/Arrow formats
- CSV support limited (slow)
- Complex joins are challenging
- Less familiar API for Pandas users
Polars: The Modern Alternative
Polars is a blazingly fast DataFrame library written in Rust with Python bindings. It's designed from the ground up for performance, using Apache Arrow as its memory model and supporting both eager and lazy execution modes.
Core Concepts
1. Eager vs Lazy Execution
import polars as pl
# EAGER MODE (executes immediately, like Pandas)
df = pl.read_csv('sales_data.csv')
result = (
df
.filter(pl.col('price') > 50)
.groupby('customer_id')
.agg(pl.col('price').sum())
)
# LAZY MODE (builds query plan, executes when needed)
df_lazy = pl.scan_csv('sales_data.csv') # scan_ for lazy
result_lazy = (
df_lazy
.filter(pl.col('price') > 50)
.groupby('customer_id')
.agg(pl.col('price').sum())
# Nothing executed yet!
)
# Optimize and execute
result = result_lazy.collect() # Now it runs (optimized)
# See the query plan
print(result_lazy.explain())
# Example: Lazy is much faster for complex pipelines
# Polars optimizes the entire chain before executing
df_lazy = (
pl.scan_parquet('sales_*.parquet')
.filter(pl.col('date') > '2024-01-01') # Pushed to file read
.select(['customer_id', 'price']) # Only reads these columns
.filter(pl.col('price') > 50)
.groupby('customer_id')
.agg(pl.col('price').mean())
)
result = df_lazy.collect() # Optimized executionExpected Output:
# Output: print(result) # customer_id price # 1001 42.15 # 1002 38.92 # ... # Query plan shows optimizations: # - Predicate pushdown (filter at file read) # - Projection pruning (only read needed columns)
2. Expression API
pl.col() to reference columns and chain operations in a functional style. This is more consistent than Pandas' mixed paradigms.import polars as pl
df = pl.read_csv('sales_data.csv')
# Expression API - chainable, optimizable
result = df.select([
pl.col('customer_id'),
pl.col('price').alias('original_price'),
(pl.col('price') * pl.col('quantity')).alias('revenue'),
pl.col('price').mean().over('customer_id').alias('avg_customer_price'),
pl.when(pl.col('price') > 100)
.then('High')
.otherwise('Low')
.alias('price_category')
])
# Powerful aggregations
summary = df.groupby('customer_id').agg([
pl.col('price').sum().alias('total_spent'),
pl.col('price').mean().alias('avg_price'),
pl.col('product_id').n_unique().alias('unique_products'),
pl.col('quantity').sum().alias('total_quantity'),
# Conditional aggregation
pl.col('price').filter(pl.col('price') > 100).sum().alias('high_value_total')
])
# Window functions (over)
df_with_ranks = df.select([
'*', # All existing columns
pl.col('price').rank().over('customer_id').alias('price_rank'),
pl.col('price').cumsum().over('customer_id').alias('cumulative_spent')
])
# String operations
df = df.with_columns([
pl.col('customer_name').str.to_uppercase().alias('name_upper'),
pl.col('customer_name').str.split(' ').alias('name_parts')
])Expected Output:
# Output: print(result) # customer_id original_price revenue avg_customer_price price_category # 0 1001 29.99 59.98 42.15 Low # 1 1002 149.99 149.99 89.32 High # ...
3. Performance Features
import polars as pl
import time
# Automatic parallelization (uses all cores)
df = pl.read_csv('large_data.csv')
start = time.time()
result = (
df
.groupby('customer_id')
.agg([
pl.col('price').sum(),
pl.col('quantity').mean(),
pl.col('product_id').n_unique()
])
)
elapsed = time.time() - start
# Streaming for larger-than-memory data
result = (
pl.scan_csv('huge_file.csv')
.filter(pl.col('price') > 50)
.groupby('customer_id')
.agg(pl.col('price').sum())
.collect(streaming=True) # Process in chunks
)
# Predicate pushdown (filter at read time)
df = pl.scan_parquet('sales_*.parquet') \
.filter(pl.col('date').is_between('2024-01-01', '2024-12-31')) \
.collect()
# Only reads relevant row groups from Parquet!
# Projection pushdown (read only needed columns)
df = pl.scan_csv('data.csv') \
.select(['customer_id', 'price']) \
.collect()
# Only reads 2 columns, not all!
# Parallel CSV parsing
df = pl.read_csv('data.csv', n_threads=16) # Uses 16 threads
# Memory-efficient joins
result = df1.join(df2, on='customer_id', how='left')
# Optimized hash join, multi-threadedExpected Output:
# Performance comparison:
print(f"Polars: {elapsed:.2f} seconds")
# Polars: 0.85 seconds
# Compare with Pandas on same data:
# Pandas: 12.3 seconds (14x slower!)When to Use Polars
✓ Advantages
- 5-10x faster than Pandas
- Multi-threaded by default
- Consistent, modern API
- Query optimization (lazy mode)
- Low memory usage (Arrow format)
- Great for production pipelines
- Excellent for streaming data
✗ Limitations
- Smaller ecosystem than Pandas
- Not a drop-in Pandas replacement
- Requires learning new API
- Less mature (fewer edge cases handled)
- Some libraries don't support it yet
- Different mental model from Pandas
Framework Comparison
| Aspect | Pandas | Dask | Vaex | Polars |
|---|---|---|---|---|
| Performance | Baseline (1x) | 2-3x (parallel) | 5-10x (aggregations) | 5-10x (general) |
| Memory Usage | High (all in RAM) | Medium (chunked) | Low (memory-mapped) | Medium-Low (Arrow) |
| Dataset Size | < 10GB | < 1TB (local) | < 1TB | < 100GB |
| Parallelization | No | Yes (automatic) | Yes (automatic) | Yes (automatic) |
| Learning Curve | Easy | Easy (if know Pandas) | Medium | Medium |
| Ecosystem | Huge | Large | Small | Growing |
| API Consistency | Mixed | Pandas-like | Custom | Excellent |
| Query Optimization | No | Yes (task graphs) | Limited | Yes (lazy mode) |
| Best Use Case | General analytics | Pandas at scale | Exploratory analysis | Production pipelines |
Real-World Example: Sales Analysis Pipeline
Let's solve the same problem with each framework to see the differences in action.
Pandas Solution
import pandas as pd
df = pd.read_parquet('sales_50gb.parquet')
filtered = df[df['customer_lifetime_value'] > 10000]
result = filtered.groupby('customer_id').agg({
'revenue': 'sum',
'order_count': 'count',
'avg_order_value': 'mean'
})
result.to_csv('high_value_customers.csv')Expected Output:
# Performance: # Time: 180 seconds # Memory: 50GB (likely OOM - out of memory!) # CPU: Single-threaded # Result: Works only if data fits in RAM
Dask Solution
import dask.dataframe as dd
ddf = dd.read_parquet('sales_50gb.parquet')
filtered = ddf[ddf['customer_lifetime_value'] > 10000]
result = filtered.groupby('customer_id').agg({
'revenue': 'sum',
'order_count': 'count',
'avg_order_value': 'mean'
}).compute()
result.to_csv('high_value_customers.csv')Expected Output:
# Performance: # Time: 60 seconds (3x faster than Pandas) # Memory: 12GB (processes in chunks) # CPU: Multi-threaded (uses all cores) # Result: Good for Pandas users scaling up
Vaex Solution
import vaex
df = vaex.open('sales_50gb.hdf5') # Instant!
filtered = df[df.customer_lifetime_value > 10000]
result = filtered.groupby('customer_id', agg={
'revenue': vaex.agg.sum('revenue'),
'order_count': vaex.agg.count(),
'avg_order_value': vaex.agg.mean('order_value')
})
result.export('high_value_customers.csv')Expected Output:
# Performance: # Time: 45 seconds (4x faster than Pandas) # Memory: 2GB (memory-mapped, minimal RAM usage) # CPU: Multi-threaded # Result: Great for exploration on massive datasets
Polars Solution
import polars as pl
df = pl.scan_parquet('sales_50gb.parquet')
result = (
df
.filter(pl.col('customer_lifetime_value') > 10000)
.groupby('customer_id')
.agg([
pl.col('revenue').sum(),
pl.count().alias('order_count'),
pl.col('order_value').mean().alias('avg_order_value')
])
.collect()
)
result.write_csv('high_value_customers.csv')Expected Output:
# Performance: # Time: 25 seconds (7x faster than Pandas!) # Memory: 8GB (optimized Arrow format) # CPU: Multi-threaded + SIMD vectorization # Result: Fastest overall, best for production pipelines
- Pandas: Familiar but slowest (180s), high memory (50GB), single-threaded
- Dask: Good for Pandas users (60s), moderate memory (12GB), multi-core
- Vaex: Great for exploration (45s), minimal memory (2GB), memory-mapped
- Polars: Fastest overall (25s), efficient memory (8GB), Rust-optimized
Best Practices
1. Choose the Right File Format
Use Parquet or HDF5, not CSV! Parquet is columnar (fast for analytics), compressed (smaller files), and supports predicate pushdown. CSV is 5-10x slower to read and much larger. Convert once, save time forever.
2. Filter Early, Filter Often
Apply filters as early as possible in your pipeline. Read only the data you need (dates, columns, conditions). With lazy evaluation (Dask/Polars), filters get pushed to the data source, reading less from disk.
3. Use Vectorized Operations
Never use .apply() with Python functions on large data! Use vectorized operations (NumPy-style) which are 100x faster. If you must use apply, consider Numba JIT compilation or switch to Polars which handles this better.
4. Monitor Memory Usage
Use df.memory_usage(deep=True) to see memory consumption. Optimize dtypes (use category for strings, int32 instead of int64 where possible). Process in chunks if hitting memory limits. Consider downcasting after operations.
5. Partition Large Datasets
Split data by meaningful dimensions (date, region, customer segment). Save as multiple files organized by partition. This enables selective reading (only load Q1 data if analyzing Q1) and better parallelization. Most frameworks support partitioned reads.
6. Start Small, Scale Up
Develop on a small sample (1% of data) with Pandas. Once code works, switch to production framework (Dask/Vaex/Polars) for full dataset. This iteration cycle is much faster than debugging on full data. Use df.sample(frac=0.01) to create representative samples.
Key Takeaways
- Pandas is your baseline - learn it first, use it for most work
- Dask scales Pandas - same API, parallel execution, larger datasets
- Vaex excels at exploration - billion-row datasets, instant opening, fast aggregations
- Polars is the fastest - modern design, Rust speed, best for production
- File format matters - use Parquet or HDF5, never CSV for large data
- Filter early - reduce data as soon as possible in your pipeline
- Vectorize operations - avoid Python loops and apply() functions
- Choose by use case - analytics (Pandas), scale (Dask), exploration (Vaex), speed (Polars)
- Memory awareness - monitor usage, optimize dtypes, partition data
- Iterate on samples - develop on small data, deploy on full dataset
What's Next?
Congratulations! You've completed all lessons. Now put everything together in the advanced capstone project to build a production-ready system.
- Capstone Project - Build a high-performance, concurrent application
- Production Deployment - Deploy and monitor your advanced Python system
- Best Practices - Apply all techniques learned throughout the course