Map Visualization, Choropleth, Continuous Color Schemes, Divergent Color Schemes, Simultaneous Animated Maps, Undirected Graphs, Subplots For Time Visualization, Line Plots, Scatter Plots and Density Plots.

Topics: [Map Visualization, Choropleth, Continuous Color Schemes, Divergent Color Schemes, Simultaneous Animated Maps, Undirected Graphs, Subplots For Time Visualization, Line Plots, Scatter Plots and Density Plots]

1) Import libraries

import json
import requests
import numpy as np
import pandas as pd
import plotly.express as px
from plotly.offline import plot   #### We need this in case we need to manually upload the interactive graphs on the blog
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from sklearn.preprocessing import MinMaxScaler

pd.options.mode.chained_assignment = None  # default='warn'

2) Data preparation

Original dataset sources:

  • https://www.kaggle.com/code/docxian/visualize-seabird-tracks/data
  • https://raw.githubusercontent.com/suchith91/wdi/master/WDI_Data_Selected.csv
  • https://www.gov.br/anac/pt-br/assuntos/dados-e-estatisticas/dados-estatisticos/dados-estatisticos

2.1) World Indicators Dataset

2.1.1) Load dataset
# This line will disappear in the portfolio page
# Step 1: Import dataset
data = pd.read_csv("https://raw.githubusercontent.com/leonardodecastro/data/main/WDI_Data_Selected.csv", encoding='cp1252').drop(['Indicator Code'], axis= 1)

# Step 2: Change dataset to allow for the use of map libraries
world_data_df = pd.melt(data, id_vars=['Country Name', 'Country Code','Indicator Name'], var_name='Year', value_name='Indicator Value')
world_data_df['Year'] = world_data_df['Year'].astype('int')
world_data_df.head(2)
Country Name Country Code Indicator Name Year Indicator Value
0 Arab World ARB CO2 emissions (metric tons per capita) 1960 0.644
1 Arab World ARB Exports of goods and services (% of GDP) 1960 NaN
2.1.2) Create datasets for specific indicator
# This line will disappear in the portfolio page
# Create dataset for GDP Growth per year
GDP_growth_df = world_data_df[world_data_df['Indicator Name'] == 'GDP growth (annual %)']

# Create value ranges for certain visualizations
bins= [GDP_growth_df['Indicator Value'].min()-1, -10, -5, 0, 5, 10, GDP_growth_df['Indicator Value'].max()+1]
labels = ['< -10 %' , '-10% to -5%', '-5% to 0%','0% to 5%','5% to 10%','> 10%']
GDP_growth_df.loc[:,'Growth Ranges']= pd.cut(GDP_growth_df['Indicator Value'], bins=bins, labels=labels, right=False).astype('str')

2.2) Flight Paths Brazil

The following dataset was created using public information. The codes used to represent airports refer to each state in Brazil. The flight information is aggregated so as to determine the amount of people that travel per year from one Brazilian state to another.

flight_path_df = pd.read_csv('https://raw.githubusercontent.com/leonardodecastro/data/main/airplane_flights_brazil.csv')

2.3) Bird Migration Dataset

2.3.1) Load dataset
bird_routes_df = pd.read_csv('https://raw.githubusercontent.com/leonardodecastro/data/main/anon_gps_tracks_with_dive.csv')
2.3.2) Clean the dataset
# This line will disappear in the portfolio page
# Step 1: Limit the dataset to the features we seek to investigate
final_bird_df = bird_routes_df[['lat','lon','bird','date_time','species','alt']]

# Step 2: Transform the date time variables
final_bird_df['date_time'] = pd.to_datetime(final_bird_df['date_time'])

# Step 3: Set the date time variable as the index do that we can resample the dataset for every 15 minutes
final_bird_df.set_index("date_time",inplace=True)
final_bird_df = final_bird_df.groupby(['bird','species']).resample('15min').mean()

# Step 4: Reset the dataset index
final_bird_df = final_bird_df.reset_index(level=1)
final_bird_df = final_bird_df.drop('bird', axis=1).reset_index()

# Step 5: Make sure the bird identifier is considered a string
final_bird_df['bird'] = final_bird_df['bird'].astype('int').astype('str')

# Step 6: Part of the analysis is focused on the Rathlin Island. Thus, we must create a dataset containing solely the data for this region. 
rathlin_island = final_bird_df[(final_bird_df['lat'] < 55.309) & (final_bird_df['lat'] > 55.254) & (final_bird_df['lon'] > -6.3194) & (final_bird_df['lon'] < -6.1416)]
C:\Users\LEONAR~1\AppData\Local\Temp/ipykernel_6752/3623230195.py:10: FutureWarning:

The default value of numeric_only in DataFrameGroupBy.mean is deprecated. In a future version, numeric_only will default to False. Either specify numeric_only or select only columns which should be valid for the function.

3) Time Series Visualizations (Choropleth)

3.1.1) Using continuous color schemes

We need to use ISO codes with 3 letters for plotly.express to work properly

# This line will disappear in the portfolio page
# Step 1: Create visualization
fig = px.choropleth(GDP_growth_df[GDP_growth_df['Year']>=1961],         # Limit the analysis to years for which that is plenty of data
                    locations="Country Code",                           # Column where country code with 3 letters can be found
                    color="Indicator Value",                            # Indicator Value is the numerical value we want to examine
                    hover_name="Country Code",                          # Column to add to hover information
                    animation_frame='Year',                             # Show column that will be used in the animation frame
                    color_continuous_scale=px.colors.diverging.RdYlGn,  # Select the type of divergent color scheme to be used
                    height = 700)                                       # Adjust the size of the figure

# Step 2: Control the speed of the transitions
fig.layout.updatemenus[0].buttons[0].args[1]['frame']['duration'] = 1000
fig.layout.updatemenus[0].buttons[0].args[1]['transition']['duration'] = 10

# Step 3: Add title to the plot
fig.update_layout(title_text='GDP Yealy Growth (%)', title_x=0.5)

# Step 4: Syle the map
fig.update_geos(showocean=True, oceancolor="#99cccc")

# Step 5: Generate an HTML file that includes the graph
#fig.write_html("graph_1.html", full_html=False, include_plotlyjs='cdn')
fig.show()

Insight: The map uses divergent colors that help determine which countries are doing better each year. However, the color legend varies every year, which means that at times red can mean small positive growth while the very same red color can mean -25% annual GDP growth. The following visualization address this limitation.

3.1.2) Using discrete color schemes

# This line will disappear in the portfolio page
# Step 1: Create a dictionary to map colors to each of the categories
color_discrete_dict = {'nan': '#4d4d4d', '< -10 %': '#d73027', '-10% to -5%' : '#fc8d59', '-5% to 0%' : '#fee08b',
                                          '0% to 5%' : '#d9ef8b', '5% to 10%' : '#91cf60', '> 10%' : '#1a9850'}

# Step 2: Create a dictionary with order of the legend labels
category_orders_dict = {'Growth Ranges' : ['nan', '< -10 %' , '-10% to -5%', '-5% to 0%','0% to 5%','5% to 10%', '> 10%']}

# Step 3: Create visualization
fig = px.choropleth(GDP_growth_df[GDP_growth_df['Year']>=1961],        # Limit the analysis to years for which that is plenty of data
                    locations="Country Code",                          # Column where country code with 3 letters can be found
                    color="Growth Ranges",                             # Indicator Value is the numerical value we want to examine
                    color_discrete_map = color_discrete_dict,          # Dictionary to map colors to each of the categories
                    category_orders= category_orders_dict,             # Dictionary with order of the legend labels
                    hover_name="Country Code",                         # Column to add to hover information
                    animation_frame='Year',                            # Show column that will be used in the animation frame
                    height = 700)                                      # Adjust the size of the figure

# Step 4: Control the speed of the transitions
fig.layout.updatemenus[0].buttons[0].args[1]['frame']['duration'] = 1000
fig.layout.updatemenus[0].buttons[0].args[1]['transition']['duration'] = 10

# Step 5: Add title to the plot
fig.update_layout(title_text='GDP Yealy Growth (%)', title_x=0.5)

# Step 6: Syle the map
fig.update_geos(showocean=True, oceancolor="#99cccc")

# Step 7: Generate an HTML file that includes the graph
#fig.write_html("graph_2.html", full_html=False, include_plotlyjs='cdn')

fig.show()

Insight: The map uses divergent colors that help determine which countries are doing better each year. The use of discrete colors help maintain color consistency for each year animation.

3.1.3) More than one animated map side by side

We first select 2 variables that we seek to investigate and create a dataframe that contain both of them.

# This line will disappear in the portfolio page
# Step 1: Select 2 variables that we seek to investigate
data = world_data_df[world_data_df['Indicator Name'].isin(['Unemployment, total (% of total labor force) (national estimate)','GDP growth (annual %)'])]

# Step 2: Convert the unemployment variable into an employment variable
data['Indicator Value'] = np.where(data['Indicator Name'] == 'Unemployment, total (% of total labor force) (national estimate)', 100 - data['Indicator Value'], data['Indicator Value'])

# Step 3: Encode these variables in terms of quantile for and easier interpration of these variables when compared later on
data['Quantile'] = data.groupby(['Indicator Name'])['Indicator Value'].transform(lambda x: pd.qcut(x, q=[0,.2,.4,.6,.8,1], labels=['Q1','Q2','Q3','Q4','Q5']))

# Step 4: Make sure the categories are in the string format
data['Quantile'] = data['Quantile'].astype('str')

# Step 5: Change certain terms for better visualization later
data['Indicator Name'] = data['Indicator Name'].map({'GDP growth (annual %)':'GDP Growth (annual %)','Unemployment, total (% of total labor force) (national estimate)':'Employment (% of total labor force)'})

Create the visualization to evaluate if GDP Growth is often negatively correlated with Unemployment Rates.

# This line will disappear in the portfolio page
# Step 1: Create a dictionary to map colors to each of the categories
color_discrete_dict = {'nan': '#4d4d4d', 'Q1': '#d7191c', 'Q2' : '#fdae61', 'Q3' : '#ffffbf',
                                          'Q4' : '#a6d96a', 'Q5' : '#1a9641'}

# Step 2: Create a dictionary with order of the legend labels
category_orders_dict = {'Quantile' : ['nan', 'Q1','Q2','Q3','Q4','Q5']}

# Step 3: Create visualization
fig = px.choropleth(data[data['Year'].isin(list(range(1980,2015)))], # Limit the analysis to years for which that is plenty of data
                    locations="Country Code",                        # Column where country code with 3 letters can be found
                    color="Quantile",                                # Indicator Value is the numerical value we want to examine
                    color_discrete_map = color_discrete_dict,        # Dictionary to map colors to each of the categories
                    category_orders= category_orders_dict,           # Dictionary with order of the legend labels
                    hover_name="Country Code",                       # Column to add to hover information
                    animation_frame='Year',                          # Show column that will be used in the animation frame
                    facet_col = 'Indicator Name')                    # Feature that determines the split into 2 columns with different types of info

# Step 4: Control the speed of the transitions
fig.layout.updatemenus[0].buttons[0].args[1]['frame']['duration'] = 1000
fig.layout.updatemenus[0].buttons[0].args[1]['transition']['duration'] = 10

# Step 5: Prevent redundant legend
names = set()
fig.for_each_trace(lambda trace: trace.update(showlegend=False) if (trace.name in names) else names.add(trace.name))

# Step 6: Prevent "Indicator Name" from appearing as the label of the fact column
fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1]))

# Step 7: Add title to the plot
fig.update_layout(title_text='Growth VS Employment', title_x=0.5)

# Step 8: Syle the map
fig.update_geos(showocean=True, oceancolor="#99cccc")

# Step 9: Generate an HTML file that includes the graph
#fig.write_html("graph_3.html", full_html=False, include_plotlyjs='cdn')

fig.show()

Insight: The map uses divergent colors that help determine which countries are doing better each year. The use of discrete colors help maintain color consistency for each year animation. Moreover, the use quantiles allows for the comparison between variables with different meanings and that often present different orders of magnitude. The animation shows that GDP Growth and Employment are often not correlated. Some countries such as the United States often present good employment metrics despite negative GDP Growth.

4) Time Series Visualizations (Undirected Graphs of Maps)

The dataset used in this part of the tutorial was created by the author by aggregating data related to 2 decades of flight paths in Brazil. The code used to aggregate such data will not be disclosed here since it would be out of the scope of this tutorial. The CSV files are available at:
https://www.gov.br/anac/pt-br/assuntos/dados-e-estatisticas/dados-estatisticos/dados-estatisticos

4.1) Visualize the flight volume between Brazilian states for 1 year

# This line will disappear in the portfolio page
# Step 1: Limit the analysis to the flight data for 2001
flight_path_df_2001 = flight_path_df[flight_path_df['Year']==2001]

# Step 2: Create a figure
fig = go.Figure()

# Step 3: Zip data in a convenient way for later use
source_to_dest = zip(flight_path_df_2001['start_lat'], flight_path_df_2001['end_lat'],
                     flight_path_df_2001['start_lon'], flight_path_df_2001['end_lon'], flight_path_df_2001['Number Passengers'])

# Step 4: Add line between the airports of origin and destination
for slat,dlat, slon, dlon, num_flights in source_to_dest:
    fig.add_trace(go.Scattergeo(lat = [slat,dlat], lon = [slon, dlon],  mode = 'lines', line = dict(width = num_flights/500000, color="lime")))

# Step 5: Create labels used in the following part
cities = flight_path_df_2001["airport_1"].values.tolist() + flight_path_df_2001["airport_2"].values.tolist()

# Step 6: Plot airports of origin and destination
fig.add_trace(go.Scattergeo(lon = flight_path_df_2001['start_lon'].values.tolist()+flight_path_df_2001['end_lon'].values.tolist(),
                            lat = flight_path_df_2001['start_lat'].values.tolist()+flight_path_df_2001['end_lat'].values.tolist(),
                            hoverinfo = 'text', text = cities, mode = 'markers', marker = dict(size = 10, color = 'orangered', opacity=0.1,)))

# Step 7: Style the map
fig.update_layout(height=700, width=1000, margin={"t":100,"b":0,"l":0, "r":0, "pad":0}, showlegend=False,
                  title_text = 'Volume of passengers between 27 Brazilian States', title_x=0.5,
                  geo = dict(projection_type = 'natural earth',scope = 'south america'))

fig.update_geos(showcountries=True, countrycolor="Black", showcoastlines=True, coastlinecolor="RebeccaPurple",
                showland=True, landcolor="#C1E1C1", showocean=True, oceancolor="#99cccc",)

# Step 8: Generate an HTML file that includes the graph
#fig.write_html("graph_4.html", full_html=False, include_plotlyjs='cdn')

# Step 9: Show the map
fig.show()

Insight: We can see that the number of passengers flying between southern States in Brazil is much higher than the flow in the northern States.

4.2) Visualize the flight volume between Brazilian states for 2 decades

We will not use animation for this visualization since it is not useful for the sake of certain report types (PDFs without the possibility of animation). Thus, it is also important to consider subplots as a visualization tool. Another reason for not using animation in this case is the fact that the go.Scattergeo does not provide this option.

# This line will disappear in the portfolio page
# Step 1: Create a list with the years for the analysis and determine the number of columns and rows for the visualization
years = list(range(2001,2022))
rows, cols = 7, 3

# Step 2: Create subplots
fig = make_subplots(rows=rows, cols=cols, subplot_titles = years, specs = [[{'type': 'scattergeo'} for c in np.arange(cols)] for r in np.arange(rows)],
                    horizontal_spacing = 0.01, vertical_spacing = 0.01)

# Step 3: Create one visualization for each year using virtually the same code as in the previous part
for i, year in enumerate(years):
  # 3.1) Limit the dataframe to a given year
  result = flight_path_df[flight_path_df['Year'] == year]

  # 3.2) Zip data in a convenient way for later use
  source_to_dest = zip(result['start_lat'], result['end_lat'],
                    result['start_lon'], result['end_lon'], result['Number Passengers'])

  # 3.3) Add line between the airports of origin and destination  
  fig.add_trace(go.Scattergeo(lon = result['start_lon'].values.tolist()+result['end_lon'].values.tolist(),
                          lat = result['start_lat'].values.tolist()+result['end_lat'].values.tolist(),
                          hoverinfo = 'text', text = cities, mode = 'markers', marker = dict(size = 10, color = 'orangered', opacity=0.1,)),
                  row = i//cols+1, col = i%cols+1)

  # 3.4) Create labels used in the following part
  for slat,dlat, slon, dlon, num_flights in source_to_dest:
      fig.add_trace(go.Scattergeo(lat = [slat,dlat], lon = [slon, dlon],  mode = 'lines', line = dict(width = num_flights/500000, color="lime")),
                  row = i//cols+1, col = i%cols+1)

  # 3.5) Plot airports of origin and destination
  cities = result["airport_1"].values.tolist() + result["airport_2"].values.tolist()

# Step 4) Style the map
  
fig.update_layout(height=1500, width=850, margin={"t":100,"b":0,"l":0, "r":0, "pad":0}, showlegend=False,
                  title_text = 'Volume of passengers between 27 Brazilian States', title_x=0.5)

fig.update_geos(projection_scale = 4, center = dict(lat=-10.33333334, lon=-53.20000000),showcountries=True, countrycolor="Black",
        showcoastlines=True, coastlinecolor="RebeccaPurple", showland=True, landcolor="#C1E1C1", showocean=True, oceancolor="#99cccc",)

# Step 5: Generate an HTML file that includes the graph
#fig.write_html("graph_5.html", full_html=False, include_plotlyjs='cdn')

# Step 6) Show the map
fig.show()

Insight: We can see that the volume of passengers flying between Brazilian States has risen significantly throughout the years. However, the COVID-19 pandemic led to a stark decrease in the number of people traveling in 2020 and 2021.

5) Time Series Visualizations (Movement Tracking)

5.1) Line plots for route representation

5.1.1) Line plots representing groups (bird species)
# This line will disappear in the portfolio page
# Create the visualization
fig = px.line_mapbox(final_bird_df, lat="lat", lon="lon", color="species", zoom=3, height=500, width = 850)

# Style the plot
fig.update_layout(mapbox_style="stamen-terrain", mapbox_zoom=4.2, mapbox_center_lat = 56, mapbox_center_lon = -2.2, margin={"r":0,"t":50,"l":0,"b":0})

# Give a title to the visualization
fig.update_layout(title_text='Bird Flying Behavior by Species', title_x=0.5)

# Generate an HTML file that includes the graph
#fig.write_html("graph_6.html", full_html=False, include_plotlyjs='cdn')
fig.show()

Insight: We can see that there are 3 species of birds that tend to fly over specific regions.

5.1.2) Line plots representing individual birds (closer look)
# This line will disappear in the portfolio page
# Limit the analysis to 6 birds that fly in a certain region
df_6_birds = final_bird_df[final_bird_df['bird'].isin(['50', '51', '52', '53', '54', '55', '56'])]

# Create the visualization
fig = px.line_mapbox(df_6_birds, lat="lat", lon="lon", color="bird", zoom=3, height=500, width = 850)

# Style the plot
fig.update_layout(mapbox_style="stamen-terrain", mapbox_zoom=8, mapbox_center_lat = 56.9, mapbox_center_lon = -2.2, margin={"r":0,"t":50,"l":0,"b":0})

# Give a title to the visualization
fig.update_layout(title_text='Bird Flying (Individual Behavior)', title_x=0.5)

# Generate an HTML file that includes the graph
#fig.write_html("graph_7.html", full_html=False, include_plotlyjs='cdn')
fig.show()

Insight: Birds in a given region seem to have different routes but all seem to pass through a certain region (might be a resting place).

5.1.3) Line plots with dots for precise locations
# This line will disappear in the portfolio page
# Limit the analysis to 1 bird 
single_bird_df = final_bird_df[final_bird_df['bird']=='55'][['date_time', 'lat','lon', 'alt']]
single_bird_df['date_time'] = single_bird_df['date_time'].astype('str')

# Create the visualization
fig = go.Figure()
fig.add_trace(go.Scattermapbox(
    mode = "markers+lines",
    lat = single_bird_df.lat.tolist(),
    lon = single_bird_df.lon.tolist(),
    marker = {'color': "blue", "size": 7} ))

# Style the plot
fig.update_layout(margin ={'l':0,'t':50,'b':0,'r':0},
                  mapbox = { 'center': {'lat': 56.8, 'lon': -2.2}, 'style': "stamen-terrain", 'zoom': 8}, height=500)

# Give a title to the visualization
fig.update_layout(title_text='Single Bird Flying Path (Line Plot)', title_x=0.5)

# Generate an HTML file that includes the graph
#fig.write_html("graph_8.html", full_html=False, include_plotlyjs='cdn')
fig.show()

Insight: Since the points refer to the location every 15 minutes, further apart points indicate faster speed. Thus, the distance traveled and therefore the speed of the bird varies greatly. The next visualization helps to strenghten this argument.

5.2) Animated Scatter Plot for Speed Visualization

# This line will disappear in the portfolio page
# Scale the altitude feature since the size argument only accepts positive values
scaler = MinMaxScaler() 
single_bird_df[['alt']] = scaler.fit_transform(single_bird_df[['alt']]) 

# Create the visualization
fig = px.scatter_mapbox(single_bird_df, lat="lat", lon="lon", size = 'alt', animation_frame = 'date_time', zoom=8, height=600)
fig.update_layout(mapbox_style="stamen-terrain")

# Control the speed of the transitions
fig.layout.updatemenus[0].buttons[0].args[1]['frame']['duration'] = 200
fig.layout.updatemenus[0].buttons[0].args[1]['transition']['duration'] = 20

# Give a title to the visualization
fig.update_layout(title_text='Single Bird Flying Path (Animated Scatter Plot)', title_x=0.5)

# Generate an HTML file that includes the graph
#fig.write_html("graph_9.html", full_html=False, include_plotlyjs='cdn')
fig.show()

Insight: We can see that the altitude of the bird varies widely during its flight (as seen by the size of the dot varying). The speed of the animal also varies significantly during the flight, as we suspected.

5.3) Density Plot for Closeness & Altitude Visualization

5.3.1) Density Plot for Closeness Visualization

Let us consider the density of points at Rathlin Island

# This line will disappear in the portfolio page
# Create the visualization
fig = px.density_mapbox(rathlin_island, lat='lat', lon='lon', zoom = 12, center=dict(lat=55.276, lon=-6.19), height=600, mapbox_style="stamen-terrain")

# Give a title to the plot
fig.update_layout(title_text='Density Plot for Closeness Visualization', title_x=0.5, margin ={'l':0,'t':50,'b':0,'r':0})

# Generate an HTML file that includes the graph
#fig.write_html("graph_10.html", full_html=False, include_plotlyjs='cdn')
fig.show()

Insight: The south of the island is where the birds are most often found.

5.3.2) Density Plot for Altitude Visualization
# This line will disappear in the portfolio page
# Create the visualization
fig = px.density_mapbox(rathlin_island, lat='lat', lon='lon', z='alt', zoom = 12, center=dict(lat=55.276, lon=-6.19), height=600, mapbox_style="stamen-terrain")

# Give a title to the plot
fig.update_layout(title_text=' Density Plot for Altitude Visualization', title_x=0.5, margin ={'l':0,'t':50,'b':0,'r':0})

# Generate an HTML file that includes the graph
#fig.write_html("graph_11.html", full_html=False, include_plotlyjs='cdn')
fig.show()

Insight: Birds tend to fly at higher altitudes when in the south of the Island.

5.3.3) Animated Density Plot for Altitude Visualization
# This line will disappear in the portfolio page
# Create the visualization
fig = px.density_mapbox(rathlin_island, lat='lat', lon='lon', z='alt', animation_frame = rathlin_island['date_time'].astype('str'),
                        zoom = 12, center=dict(lat=55.276, lon=-6.19), height=600, mapbox_style="stamen-terrain",
                        range_color = [rathlin_island['alt'].min(),rathlin_island['alt'].max()])

# Step 4: Control the speed of the transitions
fig.layout.updatemenus[0].buttons[0].args[1]['frame']['duration'] = 50
fig.layout.updatemenus[0].buttons[0].args[1]['transition']['duration'] = 15

# Step 5: Give a title to the plot
fig.update_layout(title_text='Animated Density Plot for Altitude Visualization', title_x=0.5, margin ={'l':0,'t':50,'b':0,'r':0})

# Step 6:Generate an HTML file that includes the graph
#fig.write_html("graph_12.html", full_html=False, include_plotlyjs='cdn')
fig.show()

Insight: This visualization might be a bit confusing at first since we can only see purple density plots. We could think that this contradicts the previous visualization where yellow represents birds that fly higher than others. This happens since this visualization considers the color as a representation of birds at each frame. Since birds fly together, we are bound to see the same color representing their altitudes at each point in time. If we had different colors, we would be able to affirm that many of these birds do not fly together, even when their latitudes and longitudes are approximately the same.