引力模型最初源於物理學,用以描述物體間的相互作用力,後來被廣泛應用於社會科學領域,預測地理空間中的流動現象,例如貿易、通勤與人口遷徙。在網路科學中,此模型提供了一個強大的理論框架,用以解釋節點間的連結強度或流量大小,如何受到節點自身屬性(質量)與它們之間距離的共同影響。本章節的實證分析將此理論具象化,展示如何將抽象的「質量」與「距離」概念,轉化為航空網路中可量化的指標——機場的總旅客流量與地理距離。此過程不僅是數據處理的實踐,更是將一個經典的空間互動模型,應用於複雜網路結構,以揭示其內在組織原則與流動模式的關鍵步驟,特別是在參數估計的環節,我們將探索如何從觀測數據中反推出模型的基礎常數。

航空網路的引力模型實證分析與參數估計

本章節將深入探討如何將引力模型應用於實際的航空交通數據分析。我們將詳細闡述如何計算節點的「質量」(以機場總旅客流量為例),並利用 Haversine 公式計算機場間的距離,將這些資訊整合到網路結構中。更重要的是,我們將探討如何從數據中估計引力模型中的常數乘數,這是模型擬合的關鍵步驟,儘管我們將採用一種簡化的方法來處理。

距離與質量的整合

  • Haversine 函數的實現與應用
    • 函數定義
      • 程式碼提供了 haversine(q, p) 函數的完整實現,它接收兩個包含 (latitude, longitude) 的點 qp
      • 函數內部進行了度到弧度的轉換,並應用了 Haversine 公式來計算地球表面的大圓距離。
      • R_km = 6371:設定地球半徑為 6371 公里。
      • d = R_km * c:最終計算出的距離 d 以公里為單位。
    • 外部套件的考量
      • 文中也提及,有現成的 Python 套件(如 haversinegeopy)提供了更精確的距離計算方法,並且可能處理了地球非完美球體的因素。然而,為了完整性,這裡展示了手動實現。
  • 為邊添加距離屬性
    • 迭代邊
      • for v, w in G_air.edges::遍歷網路中的每一條邊。
    • 獲取節點位置
      • p_v = (G_air.nodes[v]['lat'], G_air.nodes[v]['long'])
      • p_w = (G_air.nodes[w]['lat'], G_air.nodes[w]['long']):從節點屬性中獲取兩個端點的緯度和經度。
    • 計算並賦值距離
      • G_air.edges[v, w]['distance'] = haversine(p_v, p_w):調用 haversine 函數計算距離,並將結果作為 'distance' 屬性添加到邊 (v, w) 中。
  • 計算節點質量 (Mass)
    • 質量定義
      • 在航空交通的例子中,節點的「質量」被定義為該機場的總旅客流量。這代表了機場的規模和活躍程度。
      • 文中也幽默地提到,根據應用場景,質量也可以是人口、甚至「平均松鼠大小」,強調了質量的抽象性。
    • 計算方法
      • 總旅客流量就是機場的加權度數 (weighted degree),其中權重是邊的旅客流量 (count)。
      • degree = G_air.degree(weight='count'):使用 NetworkX 的 degree 方法,指定 weight='count' 來計算加權度數。
      • nx.set_node_attributes(G_air, dict(degree), 'degree'):將計算出的加權度數(作為質量)設置為節點的 'degree' 屬性。

引力模型參數估計(簡化方法)

  • 引力模型的假設
    • 流量 $I_{ij}$ 與節點質量乘積 $M_i M_j$ 成正比,與距離平方 $d_{ij}^2$ 成反比。
    • $I_{ij} \propto \frac{M_i M_j}{d_{ij}^2}$
    • 引入一個常數乘數 $k$:$I_{ij} = k \frac{M_i M_j}{d_{ij}^2}$。
  • 常數乘數 $k$ 的估計
    • 挑戰:在實際應用中,準確估計這個常數 $k$ 是非常重要的,它代表了流量與質量、距離關係的整體尺度。
    • 標準方法:通常會使用線性迴歸模型來擬合數據,將 $\log(I_{ij})$ 作為因變數,$\log(M_i) + \log(M_j) - 2\log(d_{ij})$ 作為自變數,來估計 $k$ 的對數。
    • 簡化方法
      • 由於線性迴歸超出了本書的範圍,這裡採用一種更簡單的近似方法。
      • 目標:對於每一條邊,計算一個「理論上」所需的常數 $k$,使得模型能夠「盡可能」地解釋觀察到的邊權重 (count)。
      • 程式碼邏輯
        • g_list = []:創建一個列表來儲存計算出的常數。
        • for v, w in G_air.edges()::迭代所有邊。
        • if v >= w: continue:為了避免重複計算(例如,邊 (A, B) 和 (B, A)),只處理其中一條(例如,當節點名稱排序時,v < w)。
        • try...except KeyError::處理邊可能不存在或缺少關鍵屬性的情況。
        • count = G_air.edges[v, w]['count']:獲取實際旅客流量。
        • mass_v = G_air.nodes[v]['degree']:獲取節點 v 的質量(加權度數)。
        • mass_w = G_air.nodes[w]['degree']:獲取節點 w 的質量。
        • distance = G_air.edges[v, w]['distance']:獲取邊的距離。
        • 計算 $k$
          • 從公式 $I_{ij} = k \frac{M_i M_j}{d_{ij}^2}$,我們可以推導出 $k = I_{ij} \frac{d_{ij}^2}{M_i M_j}$。
          • k_val = count * (distance**2) / (mass_v * mass_w):計算當前邊所需的常數 $k$。
          • g_list.append(k_val):將計算出的 $k$ 值添加到列表中。
  • 對 $k$ 值的理解
    • g_list 中的值代表了每一條邊在獨立考慮時,為了使引力模型成立所需的 $k$ 值。
    • 這些值的平均值中位數可以作為對整體常數 $k$ 的一個近似估計。
    • 這些值的分佈也能提供關於模型擬合度的信息。如果 $k$ 的值非常分散,則說明引力模型對所有邊的解釋力不均勻。

程式碼實現細節

  • Haversine 函數
    • 程式碼中提供了 haversine 函數的實現,它接收兩個點的 (lat, long) 座標。
  • 添加距離屬性
    • 遍歷所有邊,獲取節點的地理屬性,調用 haversine 計算距離,並將結果存入 G_air.edges[v, w]['distance']
  • 計算節點質量
    • 使用 G_air.degree(weight='count') 計算加權度數,並用 nx.set_node_attributes 設置為節點的 'degree' 屬性。
  • 計算常數乘數 $k$
    • 迭代邊,獲取 count, distance, mass_v, mass_w
    • 應用公式 $k = \text{count} \times (\text{distance}^2) / (\text{mass_v} \times \text{mass_w})$ 計算 $k$ 值。
    • 將 $k$ 值存入 g_list
import networkx as nx
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from pathlib import Path
import math

# --- 假設 G_air_spatial 已載入並處理好節點屬性 (lat, long, count, degree) 
---
# 為了程式碼的獨立性,這裡重新載入並處理部分數據
# 實際應用中,應接續前文的 G_air_spatial

# --- 重新創建模擬數據和網路 (如果前文未執行) 
---
data_dir_sim = Path('./data_simulated')
carrier_data_path = data_dir_sim / 'BTS2018' / 'carrier.csv'
airport_db_path = data_dir_sim / 'partow' / 'GlobalAirportDatabase.txt'

# 模擬創建 carrier.csv
if not carrier_data_path.exists():
    data_dir_sim.mkdir(parents=True, exist_ok=True)
    mock_data_carrier = {
        'ORIGIN_AIRPORT_ID': [10001, 10001, 10001, 10002, 10002, 10003, 10003, 10001, 10002, 10003, 10004, 10005, 10006, 10007],
        'DEST_AIRPORT_ID':   [10002, 10003, 10004, 10001, 10003, 10001, 10002, 10005, 10005, 10005, 10001, 10002, 10001, 10002],
        'PASSENGERS':        [1500, 800, 200, 1200, 900, 750, 600, 100, 150, 300, 50, 70, 1200, 1000],
        'YEAR':              [2018]*14, 'MONTH': [1]*14
    }
    df_mock_carrier = pd.DataFrame(mock_data_carrier)
    df_mock_carrier.to_csv(carrier_data_path, index=False)

# 模擬創建 GlobalAirportDatabase.txt
if not airport_db_path.exists():
    data_dir_partow = data_dir_sim / 'partow'
    data_dir_partow.mkdir(parents=True, exist_ok=True)
    mock_airport_db_content = """
1:JFK:John F. Kennedy International Airport:USA:40.639751:-73.778925
2:LAX:Los Angeles International Airport:USA:33.941556:-118.408530
3:ORD:Chicago O'Hare International Airport:USA:41.974209:-87.907321
4:ATL:Hartsfield-Jackson Atlanta International Airport:USA:33.640445:-84.427700
5:DEN:Denver International Airport:USA:39.856057:-104.673737
6:SFO:San Francisco International Airport:USA:37.619002:-122.371297
7:SEA:Seattle-Tacoma International Airport:USA:47.448981:-122.309310
8:MIA:Miami International Airport:USA:25.793199:-80.290594
9:DFW:Dallas/Fort Worth International Airport:USA:32.899811:-97.040416
10:LAS:McCarran International Airport:USA:36.084030:-115.153734
11:PHX:Phoenix Sky Harbor International Airport:USA:33.434250:-112.011590
12:IAH:George Bush Intercontinental Airport:USA:29.990163:-95.336770
13:BOS:Logan International Airport:USA:42.365599:-71.009667
14:MSP:Minneapolis–Saint Paul International Airport:USA:44.884794:-93.217711
15:PHL:Philadelphia International Airport:USA:39.874399:-75.242449
16:CLT:Charlotte Douglas International Airport:USA:35.214433:-80.947171
17:EWR:Newark Liberty International Airport:USA:40.689532:-74.174492
18:DTW:Detroit Metropolitan Airport:USA:42.211501:-83.353409
19:FLL:Fort Lauderdale–Hollywood International Airport:USA:26.074199:-80.150667
20:BWI:Baltimore/Washington International Airport:USA:39.177452:-76.668409
21:SLC:Salt Lake City International Airport:USA:40.789999:-111.979111
22:SAN:San Diego International Airport:USA:32.733819:-117.193751
23:TPA:Tampa International Airport:USA:27.977474:-82.531189
24:PDX:Portland International Airport:USA:45.589802:-122.597510
25:IAD:Washington Dulles International Airport:USA:38.953054:-77.456459
26:DCA:Ronald Reagan Washington National Airport:USA:38.851111:-77.037500
27:ANC:Ted Stevens Anchorage International Airport:USA:61.174355:-149.996333 # Alaska, will be removed
28:HNL:Daniel K. Inouye International Airport:USA:21.318694:-157.924694 # Hawaii, will be removed
"""
    with open(airport_db_path, 'w') as f:
        f.write(mock_airport_db_content.strip())

# --- 重新載入並處理數據 
---
G_air_spatial = nx.Graph()
airport_locations = {}

try:
    # 載入旅客流量
    df_carrier = pd.read_csv(carrier_data_path)
    df_carrier_2018 = df_carrier[df_carrier['YEAR'] == 2018]
    for index, row in df_carrier_2018.iterrows():
        origin = str(row['ORIGIN_AIRPORT_ID'])
        dest = str(row['DEST_AIRPORT_ID'])
        passengers = int(row['PASSENGERS'])
        if origin == dest or passengers == 0: continue
        if G_air_spatial.has_edge(origin, dest):
            G_air_spatial.edges[origin, dest]['count'] += passengers
        else:
            G_air_spatial.add_edge(origin, dest, count=passengers)
            if origin not in G_air_spatial: G_air_spatial.add_node(origin)
            if dest not in G_air_spatial: G_air_spatial.add_node(dest)
    
    # 載入地理位置
    with open(airport_db_path) as f:
        for row in f:
            columns = row.strip().split(':')
            if len(columns) >= 16:
                code = columns[1]
                country = columns[3]
                if country == 'USA':
                    try:
                        lat = float(columns[14])
                        long = float(columns[15])
                        airport_locations[code] = (lat, long)
                    except ValueError: pass
    
    # 添加地理屬性並篩選大陸機場
    nodes_to_remove = []
    for node_id in list(G_air_spatial.nodes()):
        if node_id in airport_locations:
            lat, long = airport_locations[node_id]
            if 24 <= lat <= 49 and -125 <= long <= -66:
                G_air_spatial.nodes[node_id]['lat'] = lat
                G_air_spatial.nodes[node_id]['long'] = long
            else:
                nodes_to_remove.append(node_id)
        else:
            nodes_to_remove.append(node_id)
    G_air_spatial.remove_nodes_from(nodes_to_remove)

    print(f"網路準備就緒: {G_air_spatial.number_of_nodes()} 個節點, {G_air_spatial.number_of_edges()} 條邊。")

except Exception as e:
    print(f"初始化網路時發生錯誤: {e}")

# --- Haversine 距離計算函數 
---
def haversine(lat1, lon1, lat2, lon2):
    """Calculate the distance between two (lat, long) points using Haversine formula."""
    R_km = 6371 # Earth radius in kilometers
    
    lat1_rad, lon1_rad, lat2_rad, lon2_rad = map(math.radians, [lat1, lon1, lat2, lon2])
    
    dlon = lon2_rad - lon1_rad
    dlat = lat2_rad - lat1_rad
    
    a = math.sin(dlat / 2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2)**2
    c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
    
    distance = R_km * c
    return distance

# --- 為邊添加距離屬性 
---
for v, w in G_air_spatial.edges():
    try:
        p_v = (G_air_spatial.nodes[v]['lat'], G_air_spatial.nodes[v]['long'])
        p_w = (G_air_spatial.nodes[w]['lat'], G_air_spatial.nodes[w]['long'])
        G_air_spatial.edges[v, w]['distance'] = haversine(p_v[0], p_v[1], p_w[0], p_w[1])
    except KeyError:
        # 如果節點缺少 lat/long 屬性,則跳過此邊的距離計算
        G_air_spatial.edges[v, w]['distance'] = None 
        
print("已為邊添加距離屬性。")

# --- 計算節點質量 (加權度數) 
---
# 使用 'count' 作為邊的權重來計算加權度數
degree_data = list(G_air_spatial.degree(weight='count'))
nx.set_node_attributes(G_air_spatial, dict(degree_data), 'mass') # 將質量命名為 'mass'

print("已計算節點質量 (總旅客流量)。")

# --- 簡化方法估計引力模型常數乘數 k 
---
k_values = [] # 儲存計算出的 k 值

for v, w in G_air_spatial.edges():
    # 為了避免重複計算 (例如,邊 (A,B) 和 (B,A)),我們只處理一次
    # 這裡假設節點 ID 是字符串,可以進行比較
    if v >= w: 
        continue
        
    try:
        count = G_air_spatial.edges[v, w]['count']
        distance = G_air_spatial.edges[v, w]['distance']
        mass_v = G_air_spatial.nodes[v]['mass']
        mass_w = G_air_spatial.nodes[w]['mass']
        
        # 確保所有值都有效,避免除以零或使用 None 值
        if count is not None and distance is not None and distance > 0 and \
           mass_v is not None and mass_w is not None and mass_v > 0 and mass_w > 0:
            
            # 從公式 k = count * (distance^2) / (mass_v * mass_w) 計算 k
            k_val = count * (distance**2) / (mass_v * mass_w)
            k_values.append(k_val)
            
    except KeyError:
        # 如果邊缺少 'count', 'distance' 或節點缺少 'mass' 屬性,則跳過
        continue
    except Exception as e:
        print(f"處理邊 ({v}, {w}) 時發生錯誤: {e}")

# --- 分析計算出的 k 值 
---
if k_values:
    k_values_np = np.array(k_values)
    
    # 計算 k 值的統計數據
    mean_k = np.mean(k_values_np)
    median_k = np.median(k_values_np)
    std_k = np.std(k_values_np)
    
    print(f"\n估計的引力模型常數 k (簡化方法):")
    print(f"  - 平均值: {mean_k:.4f}")
    print(f"  - 中位數: {median_k:.4f}")
    print(f"  - 標準差: {std_k:.4f}")
    
    # 繪製 k 值的直方圖,以了解其分佈
    plt.figure(figsize=(10, 6))
    plt.hist(k_values_np, bins=50, alpha=0.7, color='skyblue')
    plt.axvline(mean_k, color='r', linestyle='dashed', linewidth=1, label=f'Mean k ({mean_k:.2f})')
    plt.axvline(median_k, color='g', linestyle='dashed', linewidth=1, label=f'Median k ({median_k:.2f})')
    plt.title("Distribution of Calculated Gravity Model Constant 'k'")
    plt.xlabel("Value of 'k'")
    plt.ylabel("Frequency")
    plt.legend()
    plt.grid(axis='y', alpha=0.5)
    plt.show()
    
    print("\nk 值分佈分析:")
    print("  - 直方圖顯示了計算出的 k 值的分佈情況。")
    print("  - 如果 k 值相對集中,表明引力模型對數據有較好的統一解釋力。")
    print("  - 如果 k 值分佈廣泛,則意味著引力模型對不同邊的解釋力差異較大,可能需要更複雜的模型或考慮其他因素。")
    
else:
    print("\n未能計算出任何引力模型常數 k 值。請檢查數據和網路屬性。")
@startuml
!define DISABLE_LINK
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100

start

:航空網路的引力模型實證分析與參數估計;:距離與質量的整合;
note right
Haversine 函數實現:
  - 輸入: (lat, long) 座標對
  - 計算: 大圓距離 (公里)
  - 考量: 地球半徑, 度轉弧度
邊添加距離屬性:
  - 遍歷邊 (v, w)
  - 獲取節點 v, w 的 lat/long
  - 調用 haversine 計算距離
  - 存儲為 G_air.edges[v, w]['distance']
節點質量計算:
  - 定義: 機場總旅客流量 (加權度數)
  - 方法: G_air.degree(weight='count')
  - 設置節點屬性: 'mass'
end note

:引力模型參數估計 (簡化方法);
note right
引力模型公式: I_ij = k * (M_i * M_j) / d_ij^2
  - I: 實際流量 (count)
  - M: 節點質量 (mass)
  - d: 距離 (distance)
  - k: 常數乘數 (待估計)
簡化估計 k:
  - 對每條邊獨立計算 k 值
  - k = count * (distance^2) / (mass_v * mass_w)
  - 避免重複計算 (v < w)
  - 處理缺失值和除以零
k 值列表 (k_values):
  - 儲存所有計算出的 k 值
分析 k 值:
  - 計算平均值、中位數、標準差
  - 繪製 k 值分佈直方圖
  - 評估模型對數據的統一解釋力
end note

stop

@enduml

看圖說話:

此圖示總結了「航空網路的引力模型實證分析與參數估計」的內容,重點在於展示如何將計算出的距離和節點質量整合到網路中,並透過簡化的方法估計引力模型的核心參數 $k$。流程開頭首先聚焦於「距離與質量的整合」,說明了 Haversine 函數的應用、如何為邊添加距離屬性,以及如何計算節點質量(加權度數),接著詳細闡述了「引力模型參數估計 (簡化方法)」,解釋了引力模型公式,並描述了如何透過獨立計算每條邊所需的 $k$ 值,將這些值收集起來進行統計分析和視覺化,以評估模型的整體擬合情況。 縱觀引力模型在航空網路數據上的實證應用,我們不僅完成了從地理座標到網路距離、從旅客流量到節點質量的屬性整合,更重要的是,揭示了理論模型與真實世界數據之間的複雜互動。

透過對每條航線獨立估算常數 $k$ 的簡化方法,我們得到了一個分佈而非單一數值。此 $k$ 值分佈的離散程度,即成為評估模型解釋力的關鍵診斷指標。一個高度分散的結果直接表明,單純的「質量-距離」框架並不足以完整捕捉所有流量動態,這強烈暗示了票價結構、航空公司策略、樞紐機場效應等潛在變數的深遠影響。因此,這次參數估計的過程,其價值更接近於一次系統性的「壓力測試」,它所揭示的模型與現實的落差,為後續的特徵工程與模型優化提供了明確方向。

玄貓認為,對於數據分析師而言,此實證分析的核心價值,並非在於求得一個完美的常數以精確預測流量,而在於提供一個量化框架,讓我們得以系統性地識別並探索那些驅動複雜網路動態的、超越基礎引力法則的深層因素。