简介这是一份面向物流行业研究者、投资分析人员与快递企业战略部门的深度行业报告聚焦联邦快递从颠覆性创新者到空运寡头的完整成长路径并以顺丰控股为参照回答国内空运快递企业当前所处阶段与可能面临的抉择。资源共1个PDF文件包体约4.02MB报告正文约51页配合大量图表与三家国际巨头FedEx、UPS、DHL、YAMATO的市值、营收、毛利率、包裹量等对比数据便于直接引用与横向比较。报告按崛起1971—1983、扩张1983—1998、垄断1998年至今、未来威胁四条脉络展开重点解析轴辐式网络与核心枢纽处理能力对运行效率的决定作用、围绕主流需求打造护城河的路径以及单票收入下滑后盈利不确定性的成因。内容还讨论了全球化综合物流、加盟与直营兼容模式、电商自建物流跨界冲击等议题并给出衡量竞争强度、应对新进入者与判断护城河的思路。目前已有164人学习适合需要系统理解国际物流巨头经验、为战略或投研判断寻找依据的读者。1. 一份 51 页的券商 PDF真正的用法是拆成一张可查询的网络年表2019 年 7 月方正证券那份《国际物流巨头启示录之FedEx》有 51 页圈子里传得挺广。多数人从头读到尾记住一句顺丰大概处在联邦快递 80 年代初的位置然后就关掉了。拿到这类报告我的第一反应是里面那些表格能不能被代码读出来能不能用一个模型重新算一遍。这份报告真正值钱的地方是数据密度。枢纽数量从 1 个涨到 7 个、覆盖城市从 26 个扩到 310 个、全货机从 3 架到几百架、运载率从 21.95% 抬到 35.78%、1993 年隔日快递 CR3 到了 85.3% 而毛利率还在往下掉。这些数字串起来是一条可以验证的因果链比结论段落有用得多。下面这套流程是用 pdfplumber 把枢纽年表和机队运营表抽成 DataFrame用 networkx 复算 Beta/Gamma 指数用 PuLP 跑一遍枢纽选址最后把毛利率做成观测竞争强度的面板信号。手上有 PDF 研报、想做结构化分析的可以直接照抄代码全部是纯 Python 依赖。2. PDF 表格结构化枢纽添设年表与机队运营数据抽取2.1 先判断是文字型还是扫描型再决定要不要上 OCR这一步跳过后面全是白干。命令行敲pdftotext -layout report.pdf - | head -60如果能吐出成行的中文和数字就是文字型 PDF走坐标抽取路线如果只出来一堆乱码或者空白那就是图层被压平了得先 OCR成本会翻好几倍。这份方正证券的报告属于前者中文字体嵌入完整表格线是矢量线pdfplumber 能直接吃到。工具选型上没必要纠结按表格规整程度挑就行工具适用场景合并单元格/跨页依赖pdfplumber文字型 PDF有线框的数据表一般需手工拼接纯 Pythoncamelot-py有线框或无框的规则表较好支持 lattice/stream 两种模式GhostscriptPyMuPDF (fitz)只要文本和字符坐标追求速度弱预编译 wheeltabula-java命令行批量跑固定格式报表弱需要 JRE实际项目里我一般先用 PyMuPDF 做全量文本和坐标粗筛定位到目标页再用 pdfplumber 精抽表格速度能差出好几倍。2.2 pdfplumber 按坐标抽表枢纽数量、覆盖面、快递量、机队报告第 8 页那张枢纽数量/覆盖面/空运快递量/飞机数量是核心表第 11 页的机队运营效率表是第二个核心表。先抽别急着清洗。import pdfplumber import pandas as pd PDF FedEx-20190704-方正证券.pdf # 有线框的报表用 lines 策略最稳容差是给扫描件和轻微错位留的余量 TABLE_SETTINGS { vertical_strategy: lines, horizontal_strategy: lines, snap_tolerance: 3, # 相邻线条吸附阈值单位 pt join_tolerance: 3, # 断裂线条拼接阈值 intersection_tolerance: 5 # 交叉点容忍度框线粗细不匀时调大 } def dump_tables(pdf_path, page_no_1based, settingsTABLE_SETTINGS): page_no_1based 是 PDF 物理页码不是印刷页码 with pdfplumber.open(pdf_path) as pdf: page pdf.pages[page_no_1based - 1] tables page.extract_tables(settings) print(fpage {page_no_1based}: 命中 {len(tables)} 张表) for idx, tb in enumerate(tables): print(f--- table {idx} 行数{len(tb)} 列数{len(tb[0]) if tb else 0}) for row in tb[:3]: print(row) return tables dump_tables(PDF, 8) # 图表2枢纽数量与覆盖面 dump_tables(PDF, 11) # 图表7机队运营效率vertical_strategy选lines是因为这类研报的表格都有完整边框识别准确率比text策略高一个档次如果遇到没有横线的三线表再换成text并按列起点坐标聚类。intersection_tolerance是最容易被忽略的参数报告里的表格线粗细不均默认值 3 会导致某些单元格被判成空白调大之后空值明显减少。抽出来的原始结果一定是脏的。报告第 6 页的航司网络指标那行我第一次抽出来是FedEx Express 300 95 13.17 0.021看着很美实际是300 / 951 / 3.17 / 0.021被粘成了9513.17。这种粘连在中文 PDF 里非常常见靠肉眼根本发现不了必须用后面的校验规则兜住。2.3 三条校验规则把抽歪的数字挡在建模之前第一条是公式反算。报告给了一个运载率口径日均发货重量 ÷全机队最大单次载运量 × 2那个 2 代表一天一个来回。拿这条公式去反查抽出来的数任何一格对不上就说明拆错了。import pandas as pd # 图表71982—1988 年 FedEx 机队运营效率 ops pd.DataFrame({ year: [1982, 1983, 1984, 1985, 1986, 1987, 1988], fleet: [67, 76, 71, 73, 102, 145, 203], max_payload: [861.83, 1043.26, 1496.85, 1723.65, 2131.88, 2540.12, 2948.35], packages: [32_099_655, 42_439_140, 67_163_175, 103_542_495, 140_328_030, 179_619_960, 223_773_465], ship_wt_wan: [9.46, 11.17, 16.76, 26.30, 33.74, 41.55, 53.80], # 万吨 daily_wt: [371.15, 437.85, 657.09, 1031.43, 1322.98, 1629.51, 2109.68], load_factor: [0.2153, 0.2098, 0.2195, 0.2992, 0.3103, 0.3208, 0.3578], }) # 校验1运载率口径是否自洽 ops[lf_calc] ops[daily_wt] / (ops[max_payload] * 2) assert (ops[lf_calc] - ops[load_factor]).abs().max() 0.005, 运载率口径不符 # 校验2反推报告折算日判断日均口径是天还是工作日 ops[days_implied] ops[ship_wt_wan] * 10_000 / ops[daily_wt] print(ops[[year, lf_calc, load_factor, days_implied]].round(3))跑出来days_implied一列全部落在 254.9 到 255.1 之间说明报告的日均是按 255 个工作日折算的不是 365 天。这个结论很关键后面所有涉及日均的计算都得跟着走否则会凭空多出 43% 的误差。运载率反算误差也在千分位以内说明七个年份的数据全部抽对了。第二条是量纲检查。发货总量那列是万吨日均那列是吨中间差 10⁴转换时少乘一次就会得到荒谬的结论。我一般会在 DataFrame 里把单位写进列名ship_wt_wan、daily_wt这种几个月后回来看不会认错。第三条是交叉引用。报告第 8 页的机队数和第 11 页的机队数年份不完全对齐1995 年那一格在文本层里和下一行数字粘在一起我的做法是直接留空并标UNVERIFIED而不是猜一个数填进去。提示所有从 PDF 抽出来的数值进入模型之前至少要经过一次公式反算和一次量纲对齐这两步能挡掉八成的脏数据。3. 轴辐网络的量化Beta/Gamma 指数与枢纽吞吐能力3.1 为什么航空网络的连通度要用相对完全图来算轴辐式网络讲了很多年但依赖核心枢纽这句话本身是不可测量的得有指标。交通地理学里常用两个Beta 指数和 Gamma 指数。Beta 是边数除以节点数也就是平均每个通航点接几条航线Gamma 是实际边数除以理论最大边数衡量网络的连接饱和度。关键在于理论最大边数取哪个值。如果取完全图v(v-1)/2得到的 Gamma 会非常小适合区分枢纽型网络和点对点网络如果取平面图上限3(v-2)隐含假设是航线不能交叉而航空网络天然非平面孟菲斯到巴黎和纽约到东京的航路在空中是交叉的套平面公式会算出大于 1 的连通度——这显然没有物理意义。所以横向比航空网络时Gamma 一律用完全图口径。3.2 networkx 复算 FedEx 与同行的拓扑指标把第 6 页那张表抽出来用两种 Gamma 口径都算一遍对不上的格子直接标 CHECK不硬套。import pandas as pd # 图表5/6 的节点数与边数节点通航点边直飞航段 raw pd.DataFrame([ (FedEx Express, 300, 951, 3.17, 0.021), (UPS Airlines, 129, 477, 3.70, 0.058), (American Airlines, 167, 714, 4.28, 0.052), (Southwest Airlines, 73, 968, 13.26, 0.368), (SF Airlines (2018), 43, 65, 1.51, 0.667), ], columns[carrier, v, e, beta_report, gamma_report]) def topo_metrics(v, e): beta e / v gamma_complete 2 * e / (v * (v - 1)) if v 1 else float(nan) gamma_planar e / (3 * (v - 2)) if v 2 else float(nan) return beta, gamma_complete, gamma_planar for _, r in raw.iterrows(): b, gc, gp topo_metrics(r.v, r.e) flag OK if abs(b - r.beta_report) 0.05 else CHECK print(f{r.carrier:20} v{r.v:4} e{r.e:5} beta{b:6.3f} f(报告 {r.beta_report:5.2f}) {flag} fgamma_complete{gc:.3f} gamma_planar{gp:.3f})跑完的结果很清爽UPS 复算 3.698 对报告 3.70美航 4.276 对 4.28西南 13.260 对 13.26全部吻合。FedEx 复算 3.170 对报告 3.17Gamma 用完全图口径算出 0.021也对上了——前提是按 3.17 而不是被粘连出来的 13.17 去还原 e951。如果没加那个flag断言我大概会拿着 13.17 这个数琢磨半天为什么 FedEx 比西南航空还密集。顺丰航空那行则相反Beta 1.512 对得上但 Gamma 算出来 0.072报告上写的是 0.667两种口径都对不上只能标成待核。这种格子不要硬凑横向比较时先把口径统一否则会得出顺丰网络比 FedEx 更饱和的错误判断。指标本身说明什么FedEx 的 Beta 只有 3.17美航 4.28西南航空 13.26。Beta 越低说明平均每个通航点的直飞航段越少货流被迫集中到少数核心枢纽中转。这跟报告里FedEx 1.1 个核心枢纽的处理能力约为路易斯维尔的两倍因此更适合纯轴辐模式是同一件事的两种表述。西南航空的高 Beta 是另一套逻辑——它的机队是单一机型、走点对点高频次压根不是货航的玩法。3.3 枢纽吞吐能力从单日 10 万件到每小时 50 万件网络拓扑只是骨架真正决定轴辐式效率的是核心枢纽的分拣吞吐。报告里有两组数可以互相印证指标19771978197919801981复合增速孟菲斯单日可处理包裹数10.0 万13.0 万18.5 万——约 16.6%单位员工处理包裹数22872335239325282741约 4.63%次夜到货率88.5%93.5%——95%—单日处理能力四年翻近一倍靠的是自动分拣设备而不是人——员工处理效率同期只涨了 4.63%两者差了 3.5 倍。这个差距本身就是护城河的一部分枢纽的资本开支门槛把后来者挡在外面。到 2011 年孟菲斯枢纽每小时的包裹处理能力到了 50 万件路易斯维尔是 30 万件这个量级的差距不是靠多招人能追上的。import math # 复算两组复合增速确认报告口径 cagr lambda v0, v1, n: (v1 / v0) ** (1 / n) - 1 print(孟菲斯处理能力 CAGR:, round(cagr(100_000, 185_000, 4), 4)) # 0.1660 print(单位员工处理量 CAGR:, round(cagr(2287, 2741, 4), 4)) # 0.0463两个数都跟报告正文对得上说明这几列数据没有口径漂移可以放心往模型里塞。凡是复算结果和正文自述一致的表我才会用来做后续推演差得多的表先放一边。4. 新设枢纽的降本测算p-median 选址模型与 1985 年那次运载率跳变4.1 运载率台阶先算清楚一天飞几个来回1985 和 1986 两年FedEx 在已有的孟菲斯之外增设了纽瓦克和奥克兰两个枢纽运载率从 1984 年的 21.95% 直接跳到 29.92%再抬到 31.03%1987 年进一步到 32.08%。同期机型清单没有结构性变化报告据此把这次跃迁归因到航线优化而不是换飞机。我把它还原成单位成本指数做一次交叉验证# 载运率抬升对应的单位重量成本指数以 1984 年为 1.0 lf {1984: 0.2195, 1985: 0.2992, 1986: 0.3103, 1987: 0.3208} base lf[1984] for y, v in lf.items(): print(y, f载运率 {v:.2%}, f单位成本指数 {base / v:.3f}, f降幅 {1 - base / v:.2%})1985 年降 26.6%1986 年降 29.3%1987 年降 31.6%。报告里 1987 年单位重量包裹油耗同比下滑 32.62%——两个数只差一个百分点左右。方向、量级、出现时点全部对得上说明新增枢纽降本的机制里载运率提升是主项而非次要项。这个交叉验证的意义在于不看油价、不看机队光靠运载率这一个口径就能解释掉三成成本那么油价波动或者机型更替能解释的份额就很有限了。这里有个前提要交代清楚上面的推算是把单位成本近似为运载率的倒数隐含假设是航段总运力不变。1984 到 1987 年全货机从 71 架增到 145 架运力其实翻了倍所以这个模型只适合看趋势和量级不能拿去做绝对金额测算。4.2 用 PuLP 写 p-median 枢纽选址真正要回答枢纽该建在哪、建几个得把运载率背后的航段里程显式建出来。最直接的模型是 p-median给定需求点的货量和两点间距离选 p 个枢纽让所有货流经枢纽中转的总吨公里最小。import pulp, math cities [MEM, EWR, OAK, IND, ANC, MIA, GSO, CDG, SIN, CAN, NRT, LAX] coord {MEM: (35.04, -89.98), EWR: (40.69, -74.17), OAK: (37.72, -122.22), IND: (39.72, -86.29), ANC: (61.17, -149.99), MIA: (25.79, -80.29), GSO: (36.10, -79.94), CDG: (49.01, 2.55), SIN: (1.36, 103.99), CAN: (23.39, 113.30), NRT: (35.76, 140.39), LAX: (33.94, -118.41)} # 日均货量吨换成你自己的 OD 矩阵即可 demand {MEM: 120, EWR: 95, OAK: 80, IND: 60, ANC: 25, MIA: 70, GSO: 40, CDG: 110, SIN: 90, CAN: 130, NRT: 75, LAX: 100} def hav(a, b): R 6371.0 p math.radians la1, lo1, la2, lo2 p(a[0]), p(a[1]), p(b[0]), p(b[1]) return 2 * R * math.asin(math.sqrt( math.sin((la2 - la1) / 2) ** 2 math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2)) D {i: {j: hav(coord[i], coord[j]) for j in cities} for i in cities} def p_median(p): m pulp.LpProblem(fpmedian_p{p}, pulp.LpMinimize) y pulp.LpVariable.dicts(y, cities, catBinary) # 是否建枢纽 x pulp.LpVariable.dicts(x, (cities, cities), catBinary) # 需求点归属 m pulp.lpSum(demand[i] * D[i][j] * x[i][j] for i in cities for j in cities) m pulp.lpSum(y[j] for j in cities) p # 枢纽数量约束 for i in cities: m pulp.lpSum(x[i][j] for j in cities) 1 # 唯一归属 for j in cities: m x[i][j] y[j] # 只有建了才能挂 m.solve(pulp.PULP_CBC_CMD(msgFalse)) hubs [j for j in cities if y[j].value() 0.5] return hubs, pulp.value(m.objective) base None for p in (1, 2, 3, 4): hubs, cost p_median(p) base base or cost print(fp{p} 枢纽{hubs} 总吨公里{cost:,.0f} 相对p1降幅{1 - cost / base:.1%})参数上有三处可以调demand换成真实的 OD 货量矩阵D换成实际的航路距离或者按航班频次加权的成本矩阵p就是要评估的枢纽数量。目标函数的形式决定了模型的行为——demand[i] * D[i][j]是线性成本如果业务上存在规模效应枢纽间航班越密单吨成本越低就要把目标改成demand * f(D)的分段线性形式否则模型会低估枢纽的作用。跑出来的规律是从 1 个枢纽加到 2 个降幅最大通常在三成上下加到第三个开始边际递减。这跟报告引用的傅强、肖士强2016《轴辐式航空货运航线网络的构建研究》里那句已有 1-2 个枢纽的情况下新增枢纽可降低 30%-40% 运输成本基本一致。所以新设枢纽划不划算不是拍脑袋是能被算出来的关键是你得把需求矩阵和成本函数写对。4.3 纯轴辐到点对点混合给模型加一个直飞变量上面这个模型有个硬伤它强制所有货流都经枢纽中转。现实里如果两个需求点离得很近、货量又大直飞的成本比绕一圈更低。这正是报告说的网络从小范围专线走到单枢纽再到大范围多枢纽最后变成多轴辐点对点混合的技术原因——不是管理层想变是纯轴辐在大范围覆盖下不再最优。补法很简单加一组直飞变量并给经枢纽的航段加一个折扣系数 α典型值 0.5 到 0.7代表枢纽间大飞机的单位成本优势# 在 p_median 基础上增加 z[i][j]i 到 j 直飞 z pulp.LpVariable.dicts(z, (cities, cities), catBinary, ) alpha 0.6 # 枢纽间单位成本折扣 for i in cities: for j in cities: if i ! j: # 成本项拆成两部分经枢纽走 alpha*(d(i,k)d(k,j))直飞走 d(i,j) m demand[i] * D[i][j] * z[i][j] m pulp.lpSum(z[i][j] for j in cities if j ! i) \ pulp.lpSum(x[i][j] for j in cities) 2 # 每个点最多一种出线方式alpha是最敏感的参数调到 0.8 以上模型几乎全选枢纽中转退化成纯轴辐调到 0.4 以下模型会大量选择直飞退化成点对点专线网。实做的时候我会拿历史网络结构反推 α让模型复现 1983 年的单枢纽和 1995 年的七枢纽这两个状态再把 α 固定下来去预测下一步而不是凭经验拍一个值。注意p-median 的目标函数是线性距离成本它算不出返程空仓这种与方向强相关的成本。真要做单程货流不对称的测算得把 OD 拆成有向图或者直接上带容量约束的枢纽选址模型。5. 竞争强度观测把毛利率做成面板信号集中度只做参考5.1 CR3 高不等于竞争缓和这份报告里最反直觉的一个结论1993 年隔日快递和隔夜快递的 CR3 分别到了 85.3% 和 86.2%头部集中度看起来已经很高了但同期毛利率和单票收入还在往下走一点修复的迹象都没有。1994 年毛利确实回升了但报告明确写了这是因为剥离了大幅亏损的欧洲本土快递业务属于口径变动不是竞争态势改善。所以集中度是市场份额的相对指标反映的是谁排第几不反映打得多凶。真正能观测竞争强度的量是毛利率和细分产品的单票收入。把这条经验落到代码里就是一套面板信号连续若干期毛利率均值拐头向上同时单票收入同比不再下滑才认定为竞争缓和的初筛信号并且必须叠加一个口径变动标记把业务剥离、并购那几期剔掉。5.2 面板信号的代码实现import pandas as pd # 示意数据替换成从年报/10-K 里扒的真实值 df pd.DataFrame({ year: [1991, 1992, 1993, 1994, 1995, 1996, 1997], margin: [0.121, 0.115, 0.109, 0.123, 0.128, 0.131, 0.129], yield_yoy: [-0.03, -0.04, -0.05, 0.01, 0.01, 0.02, 0.01], scope_change: [0, 0, 0, 1, 0, 0, 0], # 1 当期存在业务剥离或并购 }).set_index(year) df[margin_ma] df[margin].rolling(3, min_periods3).mean() df[turn_up] (df[margin_ma].diff() 0) \ (df[yield_yoy] df[yield_yoy].shift(1)) # 口径变动的影响通常残留 2-3 个报告期向前滚 3 期一起屏蔽 df[credible] df[turn_up] \ (df[scope_change].rolling(3, min_periods1).sum() 0) print(df.round(4)) print(可信的毛利率拐点年份, list(df.index[df[credible]]))三个参数值得说清楚。滚动窗口取 3是因为报告的阶段划分本身就是三到五年一个周期窗口太短会被单期异常值带偏太长会钝化拐点。scope_change向前滚 3 期而不是只屏蔽当期是因为剥离业务的毛利率影响很少在一期内消化完通常跨两到三个报告期。yield_yoy必须和毛利率一起看只有单票收入止跌毛利率回升才是竞争缓和否则很可能只是成本端比如油价在帮忙。这套面板还有一个用法把credible为真的年份和当年的运力投放计划摆在同一张表里对照。历史上几次毛利率修复都伴随新增枢纽投产或者机队更新也就是说供给端的主动调整往往先于价格端修复出现。盯这个领先关系比盯 CR3 那个数字有用得多。本文还有配套的精品资源点击获取