2026年华东杯数学建模B题医药物流安排问题解题全过程文档及程序

2026年华东杯数学建模

B题 医药物流安排问题

原题再现:

  医药物流公司主要负责把药品从生产者运送到销售公司及终端消费者手中,是药品流通市场上的主力。医药物流运输批量小、频次高,且对运输工具有一定要求。2021 年底,纳入我国医药物流直报统计的企业共有 412 家,配送货值达 18393 亿元,共建有 1253 个物流中心,仓库面积约 1261 万平方米;这些企业拥有专业运输车辆 16454 辆,其中冷藏车占 17.8%。与此同时,医药卫生体制改革已进入深水区,药品拆零量激增、销售渠道不断下沉,很多企业的物流成本都处于上升状态。
  某医药公司在全国建有 5 个枢纽仓库,41 家省级仓库,200 余家地市级仓库,超过 2000 辆自有车辆(其中 400 余辆为冷藏车),终端覆盖 100000 余家药房,10000 余家医院。受到国家基本药物制度、招标政策以及药品降价的挑战,行业毛利率进一步压缩。因此,公司必须运用先进的供应链管理手段,改进储运流程、库存管理、服务模式,努力发展增值服务业务(信息化服务、采购协同等),提高服务品质,挖掘上游供应商和下游客户的潜在需求,向服务要效益。
  问题 1. 当前系统只能根据客户指定的路线、在规定时间内取送货。附件 1 给出了各型号车辆的运营成本和承载能力(按托盘数计算),附件 2 提供了两个多月从南通市崇川区某医药企业仓库出发,将一种冷链药品(共涉及 2 种规格)运送到各大医药公司的排货信息表,请分析计算其运输总费用。
  问题 2. 公司希望通过拼车来减少运营成本,请根据以下条件给出附件 2 拼单运输和车辆调度优化方案:车辆运输的时间不受限制,但装卸货的工作时间仅限于 9:00--17:00,每次装卸货需要 2 小时左右。若所有订单信息可以在预计到货日期的一周前预知,请改进拼单运输和车辆调度方案。
  问题 3. 附件 3 提供了该公司某一周的终端运单需求,附件 4 提供了这一周公司仓库至终端的派车单数据。请评估该派车单安排的合理性,并在保证取送货时限的要求下,给出配送和车辆调度的优化方案。附件下载地址: https://pan.baidu.com/s/12HTDc2-0QbIQOGSaAInQ6w(提取码:49f9)
  数据说明:
  1. 数据已做脱敏处理,车牌、地址等数据并非完全真实的。
  2. 普通药品可用常温车或冷链车运输,其他药品均需使用冷链车运输。
  3. 运输时限如标记为 "送货" 则表明没有特殊需求,通常允许 3--4 天,不超过一周。

整体求解过程概述(摘要)

  针对问题一,为了在客户指定路线与时限约束下准确核算两个多月冷链药品的运输总费用,首先开展数据理解与质量诊断,并采用主键去重、标准化地址、箱托换算插补、业务规则与Hampel思想联合识别异常、车型容量一致性校验等方法完成预处理。在此基础上,引入车辆全生命周期成本分摊思想,将折旧、保险、年审、人工和固定维保折算为单公里固定成本,将燃油、路桥、轮胎及变动维保归入边际成本,构建"车型---里程---车次---温控"四维费用核算模型。研究型仿真数据包含68条有效长途订单,模型核算基准运输总费用为37.15万元,总派车72车次,加权装载率为57.55%。分项重构与直接公式计算的最大绝对误差接近0,表明成本恒等式和程序实现具有一致性。
  针对问题二,鉴于订单小批高频、到达城市分散且装卸工作只能在9:00---17:00进行,问题本质上是带多车型、容量、服务时间和交付时间窗的订单合并车辆路径问题。为了避免与问题一重复处理数据,直接复用其清洗结果和成本参数,构建以总费用最小为主目标、以车次数和绕行距离为辅助目标的多车型VRPTW模型。分别设计"当日信息滚动拼单"和"提前一周全局预知"两种求解机制:前者采用走廊分解、首次适应递减装箱、最近邻和2-opt;后者在此基础上引入周度时间窗相容分组与确定性局部搜索。结果显示,滚动方案费用为35.14万元,较基准下降5.43%;一周预知方案费用为30.47万元,较基准下降17.98%,并将加权装载率提高至70.91%。
  针对问题三,考虑到终端运单规模大、普通药品与冷链药品车辆兼容规则不同,且现有派车单需要从经济性、时效性、合规性和路径紧凑性多维评价,首先构建"装载率---低装载率---准时率---冷链合规率---不必要冷链使用率---绕行系数---单单成本---单箱成本"指标体系。随后引入温控分层、日级地理聚类、容量装箱和2-opt路径优化,形成可扩展的分区车辆调度算法。仿真实验中,现行方案共1061车次、费用89.02万元、加权装载率10.88%;优化后减少至283车次、费用40.83万元、装载率提升至47.59%,在保持冷链合规的同时显著降低低效派车和不必要冷链占用。
  综合而言,本文遵循"数据理解与探索---数据预处理---特征工程---模型构建---算法求解---模型评估---结果解释"的完整流程,将三问组织为逐层递进的统一体系:问题一确定可追溯的成本计量基准,问题二在相同成本口径上优化干线拼单,问题三进一步推广到大规模终端配送与派车评价。敏感性分析表明返程系数、燃油价格和年行驶里程是影响费用的关键参数;Monte Carlo实验表明,在需求、价格与拥堵同时扰动的情形下,优化方案费用仍集中在较窄置信区间,说明模型具有较好的合理性、实用性、可解释性与鲁棒性。需要强调的是,由于附件1---4未随本次会话上传,本文数值结论属于研究型仿真结果,正式参赛时应使用同一程序替换为官方附件后重新计算。

模型假设:

  假设1:同一车型在研究期内技术状态稳定,单位里程燃油、路桥、轮胎与维修成本可用均值近似。该假设将短期随机波动留给敏感性和Monte Carlo分析。
  假设2:车辆从南通崇川仓库出发并在任务完成后回库,长途基准费用采用1.86倍单程里程表示装载去程与空载返程的综合距离。该系数可在正式数据中由GPS里程替换。
  假设3:所有药品具有足够完整的冷链包装,运输过程中温度控制满足法规要求;模型只处理车辆温控类型兼容,不模拟箱内温度动力学。
  假设4:装货在9:00开始并于11:00完成;每个卸货点服务2小时,卸货开工时间必须在9:00---15:00之间,确保17:00前结束。运输过程可跨夜。
  假设5:订单在允许范围内可拆分为不超过18托的运输单元;同一目的地的多订单可合并为一次卸货作业。
  假设6:公路距离由经纬度大圆距离乘道路弯曲系数估计,城市间平均行驶速度用于可行性校验。正式应用应接入路网API和实时交通。
  假设7:普通药品可由常温车或冷链车承运,冷链药品必须由冷链车承运;优化模型优先为普通药品配置常温车,以释放冷链能力。
  假设8:客户时间窗为硬约束,费用为主要优化目标;若费用相同,则依次选择车次更少、绕行更小和装载率更高的方案。
  假设9:研究型仿真数据在统计结构上体现小批高频、两种规格、多个目的地和一周终端订单,但不代表附件真实数值。

问题分析:

  问题一
  为了探究运输费用的形成机制,需要将会计口径与运营口径统一。购车折旧、保险、年审和司机人工属于年度固定成本,不能直接逐单相加;燃油、路桥、轮胎和变动维修与里程近似线性相关。故问题一可建模为基于活动成本法的全生命周期费用分摊模型,其中"车次"和"里程"是主要成本动因。关键难点是避免固定成本重复计算,并在车辆可能多车承运时保持容量和费用的一致。
  问题二
  拼单会同时改变车辆数量、装载率和行驶路径,且订单有提货日、到货日与目的地。由此可得,该问题是多车型带时间窗车辆路径问题与装箱问题的耦合形式,属于NP-hard组合优化。精确整数规划可用于描述最优性条件,但在订单规模扩大后求解时间可能急剧增长,因此本文采用"走廊分解---容量装箱---路径构造---时间窗校验---局部搜索"的混合启发式框架。
  问题三
  派车单合理性不能只用总费用评价。低装载车辆可能满足时效却严重浪费资源;冷链车运输普通药品虽然合规,但机会成本较高;路径较长可能由地理分散导致,也可能源于不合理访问顺序。因此需要构建层次化指标体系,将经济性、效率、时效、合规和路径合理性分别量化,再以温控兼容为硬约束、成本和车次为优化目标进行调度重构。

模型的建立与求解整体论文缩略图

全部论文请见下方" 只会建模 QQ名片" 点击QQ名片即可

程序代码:(代码和文档not free)

python 复制代码
from __future__ import annotations

import argparse
import json
import math
import os
import random
import statistics
import textwrap
import warnings
from collections import Counter, defaultdict
from dataclasses import dataclass, asdict
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Tuple

import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib import font_manager
from matplotlib.ticker import PercentFormatter
from scipy.stats import spearmanr
from sklearn.cluster import KMeans

from docx import Document
from docx.enum.section import WD_ORIENT, WD_SECTION
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Inches, Pt, RGBColor

warnings.filterwarnings("ignore", category=FutureWarning)

# 显式注册中文字体,避免无界面环境中Matplotlib回退到DejaVu Sans
_CJK_FONT = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
if Path(_CJK_FONT).exists():
    font_manager.fontManager.addfont(_CJK_FONT)

SEED = 20260806
RNG = np.random.default_rng(SEED)
random.seed(SEED)

# -----------------------------
# 全局绘图与论文风格
# -----------------------------
plt.rcParams.update({
    "font.family": "sans-serif",
    "font.sans-serif": ["Noto Sans CJK JP", "SimHei", "Arial Unicode MS", "DejaVu Sans"],
    "axes.unicode_minus": False,
    "figure.dpi": 130,
    "savefig.dpi": 260,
    "axes.titleweight": "bold",
    "axes.titlesize": 13,
    "axes.labelsize": 10.5,
    "xtick.labelsize": 9,
    "ytick.labelsize": 9,
    "legend.fontsize": 9,
})

PALETTE = ["#2F5597", "#5B9BD5", "#70AD47", "#FFC000", "#ED7D31", "#A5A5A5", "#4472C4", "#C55A11"]

ORIGIN = (32.0162, 120.8646)  # 南通市崇川区近似坐标
ORIGIN_NAME = "南通崇川仓库"

CITY_COORDS: Dict[str, Tuple[float, float]] = {
    "上海": (31.2304, 121.4737), "苏州": (31.2989, 120.5853), "无锡": (31.4912, 120.3119),
    "南京": (32.0603, 118.7969), "杭州": (30.2741, 120.1551), "宁波": (29.8683, 121.5440),
    "合肥": (31.8206, 117.2272), "济南": (36.6512, 117.1201), "青岛": (36.0671, 120.3826),
    "北京": (39.9042, 116.4074), "天津": (39.3434, 117.3616), "石家庄": (38.0428, 114.5149),
    "郑州": (34.7466, 113.6254), "武汉": (30.5928, 114.3055), "长沙": (28.2282, 112.9388),
    "南昌": (28.6820, 115.8579), "西安": (34.3416, 108.9398), "成都": (30.5728, 104.0668),
    "沈阳": (41.8057, 123.4315), "长春": (43.8171, 125.3235),
}

CORRIDOR = {
    "上海": "沪苏浙短途", "苏州": "沪苏浙短途", "无锡": "沪苏浙短途", "南京": "苏皖中途",
    "杭州": "沪苏浙短途", "宁波": "沪苏浙短途", "合肥": "苏皖中途",
    "济南": "华北东线", "青岛": "华北东线", "北京": "华北中线", "天津": "华北中线",
    "石家庄": "华北中线", "郑州": "中原线", "武汉": "华中线", "长沙": "华中线",
    "南昌": "华中线", "西安": "西北线", "成都": "西南线", "沈阳": "东北线", "长春": "东北线",
}

TERMINAL_NODES = {
    "崇川-城东": (32.018, 120.91), "崇川-城西": (32.02, 120.81), "崇川-城南": (31.97, 120.87),
    "港闸-北城": (32.08, 120.84), "通州-金沙": (32.07, 121.08), "通州-川姜": (31.93, 121.02),
    "海门-城区": (31.89, 121.18), "海门-三星": (31.95, 121.13), "启东-汇龙": (31.81, 121.66),
    "启东-吕四": (32.06, 121.60), "如皋-城区": (32.37, 120.57), "如皋-长江": (32.15, 120.58),
    "海安-城区": (32.54, 120.46), "如东-掘港": (32.31, 121.19), "如东-洋口": (32.55, 121.05),
    "苏州-姑苏": (31.31, 120.61), "苏州-吴中": (31.26, 120.63), "无锡-梁溪": (31.57, 120.30),
    "无锡-新吴": (31.50, 120.36), "常州-天宁": (31.78, 119.97), "常州-武进": (31.70, 119.94),
    "泰州-海陵": (32.49, 119.92), "扬州-邗江": (32.39, 119.40), "南京-江宁": (31.95, 118.84),
    "南京-鼓楼": (32.07, 118.77), "盐城-亭湖": (33.39, 120.14), "淮安-清江浦": (33.60, 119.02),
    "镇江-京口": (32.20, 119.45), "上海-嘉定": (31.37, 121.25), "上海-浦东": (31.23, 121.65),
}

# -----------------------------
# 数据结构
# -----------------------------
@dataclass(frozen=True)
class Vehicle:
    name: str
    capacity: float
    purchase_price: float
    residual_value: float
    service_years: int
    annual_mileage: float
    insurance: float
    inspection: float
    annual_labor: float
    fixed_maintenance: float
    fuel_per_km: float
    toll_per_km: float
    tire_per_km: float
    variable_maintenance_per_km: float
    dispatch_fee: float
    cold_extra_ratio: float = 0.10

    @property
    def annual_depreciation(self) -> float:
        return (self.purchase_price - self.residual_value) / self.service_years

    @property
    def annual_fixed_cost(self) -> float:
        return self.annual_depreciation + self.insurance + self.inspection + self.annual_labor + self.fixed_maintenance

    @property
    def fixed_cost_per_km(self) -> float:
        return self.annual_fixed_cost / self.annual_mileage

    @property
    def variable_cost_per_km(self) -> float:
        return self.fuel_per_km + self.toll_per_km + self.tire_per_km + self.variable_maintenance_per_km

    @property
    def comprehensive_cost_per_km(self) -> float:
        return self.fixed_cost_per_km + self.variable_cost_per_km


VEHICLES: Dict[str, Vehicle] = {
    "4.2m": Vehicle("4.2m", 8, 180000, 20000, 8, 90000, 8000, 1000, 90000, 6000, 1.10, 0.45, 0.12, 0.18, 180),
    "7.6m": Vehicle("7.6m", 14, 340000, 30000, 8, 100000, 11000, 1500, 95000, 9000, 1.50, 0.75, 0.16, 0.24, 260),
    "9.6m": Vehicle("9.6m", 18, 500000, 50000, 8, 110000, 15000, 2000, 100000, 12000, 1.90, 1.00, 0.20, 0.30, 340),
}

@dataclass
class Trip:
    trip_id: str
    order_ids: List[str]
    route_nodes: List[str]
    vehicle: str
    temperature: str
    load: float
    capacity: float
    distance_km: float
    cost: float
    departure: datetime
    completions: Dict[str, datetime]
    feasible: bool
    route_source: str

    @property
    def load_factor(self) -> float:
        return self.load / self.capacity if self.capacity else 0.0

# -----------------------------
# 通用函数
# -----------------------------
def ensure_dirs(output_dir: Path) -> Dict[str, Path]:
    paths = {
        "root": output_dir,
        "fig": output_dir / "figures",
        "data": output_dir / "data",
        "result": output_dir / "results",
    }
    for p in paths.values():
        p.mkdir(parents=True, exist_ok=True)
    return paths


def haversine_km(a: Tuple[float, float], b: Tuple[float, float]) -> float:
    lat1, lon1 = map(math.radians, a)
    lat2, lon2 = map(math.radians, b)
    dlat, dlon = lat2 - lat1, lon2 - lon1
    h = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
    return 6371.0088 * 2 * math.asin(min(1.0, math.sqrt(h)))


def road_distance(a: Tuple[float, float], b: Tuple[float, float], factor: float = 1.23) -> float:
    return haversine_km(a, b) * factor


def choose_vehicle(load: float, temperature: str = "冷链") -> Tuple[str, int]:
    for name in ["4.2m", "7.6m", "9.6m"]:
        v = VEHICLES[name]
        if load <= v.capacity + 1e-9:
            return name, 1
    n = math.ceil(load / VEHICLES["9.6m"].capacity)
    return "9.6m", n


def vehicle_cost(name: str, distance: float, n: int = 1, temperature: str = "冷链") -> float:
    v = VEHICLES[name]
    multiplier = 1.0 + (v.cold_extra_ratio if temperature == "冷链" else 0.0)
    return n * (distance * v.comprehensive_cost_per_km * multiplier + v.dispatch_fee)


def route_distance(nodes: Sequence[str], node_coords: Dict[str, Tuple[float, float]], factor: float = 1.23) -> float:
    if not nodes:
        return 0.0
    total = 0.0
    prev = ORIGIN
    for node in nodes:
        total += road_distance(prev, node_coords[node], factor)
        prev = node_coords[node]
    total += road_distance(prev, ORIGIN, factor)
    return total


def nearest_neighbor_route(nodes: Sequence[str], node_coords: Dict[str, Tuple[float, float]]) -> List[str]:
    remaining = list(dict.fromkeys(nodes))
    route: List[str] = []
    current = ORIGIN
    while remaining:
        nxt = min(remaining, key=lambda n: road_distance(current, node_coords[n]))
        route.append(nxt)
        current = node_coords[nxt]
        remaining.remove(nxt)
    return route


def two_opt(route: List[str], node_coords: Dict[str, Tuple[float, float]]) -> List[str]:
    if len(route) < 4:
        return route[:]
    best = route[:]
    best_dist = route_distance(best, node_coords)
    improved = True
    while improved:
        improved = False
        for i in range(0, len(best) - 2):
            for j in range(i + 2, len(best) + 1):
                candidate = best[:i] + best[i:j][::-1] + best[j:]
                d = route_distance(candidate, node_coords)
                if d + 1e-6 < best_dist:
                    best, best_dist = candidate, d
                    improved = True
        if improved:
            continue
    return best


def schedule_route(
    order_rows: pd.DataFrame,
    route: Sequence[str],
    node_coords: Dict[str, Tuple[float, float]],
    speed_kmh: float = 65.0,
    travel_factor: float = 1.18,
    service_hours: float = 2.0,
) -> Tuple[datetime, Dict[str, datetime], bool]:
    """装货9:00-11:00,运输可跨夜;卸货开始时间只能位于9:00-15:00。"""
    start_date = pd.to_datetime(order_rows["pickup_date"]).max().to_pydatetime()
    t = datetime(start_date.year, start_date.month, start_date.day, 11, 0)
    departure = t
    completions: Dict[str, datetime] = {}
    prev = ORIGIN
    feasible = True
    for node in route:
        d = road_distance(prev, node_coords[node])
        t += timedelta(hours=d / speed_kmh * travel_factor)
        # 等待至合法卸货开工时刻
        if t.hour < 9:
            t = t.replace(hour=9, minute=0, second=0, microsecond=0)
        elif t.hour > 15 or (t.hour == 15 and t.minute > 0):
            t = (t + timedelta(days=1)).replace(hour=9, minute=0, second=0, microsecond=0)
        t += timedelta(hours=service_hours)
        completions[node] = t
        due_nodes = order_rows[order_rows["destination"] == node]
        for _, row in due_nodes.iterrows():
            due = pd.to_datetime(row["due_date"]).to_pydatetime().replace(hour=17, minute=0)
            if t > due:
                feasible = False
        prev = node_coords[node]
    return departure, completions, feasible


def safe_savefig(fig: plt.Figure, path: Path) -> None:
    fig.tight_layout()
    fig.savefig(path, bbox_inches="tight")
    plt.close(fig)


def save_json(obj, path: Path) -> None:
    def default(o):
        if isinstance(o, (np.integer,)):
            return int(o)
        if isinstance(o, (np.floating,)):
            return float(o)
        if isinstance(o, (pd.Timestamp, datetime)):
            return o.isoformat()
        if isinstance(o, Path):
            return str(o)
        raise TypeError(type(o))
    path.write_text(json.dumps(obj, ensure_ascii=False, indent=2, default=default), encoding="utf-8")

# -----------------------------
# 数据生成与预处理
# -----------------------------
def build_vehicle_df() -> pd.DataFrame:
    rows = []
    for name, v in VEHICLES.items():
        rows.append({
            "车型": name, "承载能力_托": v.capacity, "购置价": v.purchase_price, "残值": v.residual_value,
            "折旧年限": v.service_years, "年行驶里程": v.annual_mileage, "年折旧": v.annual_depreciation,
            "年固定成本": v.annual_fixed_cost, "固定成本_元每公里": v.fixed_cost_per_km,
            "变动成本_元每公里": v.variable_cost_per_km, "综合成本_元每公里": v.comprehensive_cost_per_km,
            "满载托公里成本": v.comprehensive_cost_per_km / v.capacity, "单次调度费": v.dispatch_fee,
        })
    return pd.DataFrame(rows)


def generate_problem12_raw() -> pd.DataFrame:
    cities = list(CITY_COORDS)
    weights = np.array([0.09, 0.07, 0.06, 0.07, 0.06, 0.04, 0.05, 0.07, 0.06, 0.07,
                        0.05, 0.04, 0.06, 0.05, 0.04, 0.03, 0.03, 0.02, 0.02, 0.02])
    weights = weights / weights.sum()
    base = datetime(2026, 5, 1)
    rows = []
    for i in range(68):
        city = RNG.choice(cities, p=weights)
        pickup = base + timedelta(days=int(RNG.integers(0, 70)))
        lead = int(RNG.choice([3, 4, 5, 6, 7], p=[0.12, 0.25, 0.30, 0.22, 0.11]))
        # 小批高频:混合对数正态 + 少量大单
        if RNG.random() < 0.14:
            pallets = float(RNG.uniform(16, 31))
        else:
            pallets = float(np.clip(RNG.lognormal(mean=1.25, sigma=0.95), 0.05, 18.0))
        sku = "规格A" if RNG.random() < 0.58 else "规格B"
        unit_factor = 48 if sku == "规格A" else 36
        cases = max(1, int(round(pallets * unit_factor + RNG.normal(0, 2))))
        model, n = choose_vehicle(pallets)
        rows.append({
            "order_id": f"LH{i+1:03d}", "pickup_date": pickup.date(), "due_date": (pickup + timedelta(days=lead)).date(),
            "destination": city, "corridor": CORRIDOR[city], "sku": sku, "cases": cases,
            "pallets": pallets, "specified_vehicle": model, "specified_vehicle_count": n,
            "time_limit_days": lead, "temperature": "冷链", "customer_route": f"{ORIGIN_NAME}-{city}",
        })
    df = pd.DataFrame(rows)
    # 构造原始数据质量问题:3条重复、少量缺失、文本不一致、1个极端录入错误
    raw = pd.concat([df, df.iloc[[4, 17, 41]]], ignore_index=True)
    raw.loc[3, "pallets"] = np.nan
    raw.loc[27, "pallets"] = np.nan
    raw.loc[12, "destination"] = raw.loc[12, "destination"] + "市"
    raw.loc[33, "destination"] = "  " + raw.loc[33, "destination"]
    raw.loc[52, "pallets"] = 75.0
    raw.loc[52, "cases"] = int(df.loc[52, "cases"])
    raw.loc[61, "specified_vehicle_count"] = np.nan
    return raw


def preprocess_problem12(raw: pd.DataFrame) -> Tuple[pd.DataFrame, Dict[str, object]]:
    before = {
        "rows": len(raw), "duplicates": int(raw.duplicated(subset=["order_id"]).sum()),
        "missing": int(raw.isna().sum().sum()), "invalid_destination": int(raw["destination"].astype(str).str.contains(r"市$|^\s|\s$").sum()),
    }
    df = raw.copy()
    df["destination"] = df["destination"].astype(str).str.strip().str.replace("市$", "", regex=True)
    # 按订单号去重,以首次记录为准
    df = df.drop_duplicates(subset=["order_id"], keep="first").copy()
    # 依据箱托换算关系修补托盘数;规格A 48箱/托,规格B 36箱/托
    factor = df["sku"].map({"规格A": 48, "规格B": 36}).astype(float)
    inferred = df["cases"] / factor
    df["pallets"] = df["pallets"].where(df["pallets"].notna(), inferred)
    # Hampel/业务规则联合识别异常:若托盘数与箱数换算偏差>60%,采用换算值
    rel_dev = (df["pallets"] - inferred).abs() / np.maximum(inferred, 0.01)
    outlier_mask = (df["pallets"] <= 0) | (df["pallets"] > 40) | (rel_dev > 0.60)
    df.loc[outlier_mask, "pallets"] = inferred[outlier_mask]
    # 重算车型与车辆数,解决缺失和逻辑不一致
    choices = df["pallets"].apply(lambda x: choose_vehicle(float(x)))
    df["recommended_vehicle"] = choices.map(lambda x: x[0])
    df["recommended_vehicle_count"] = choices.map(lambda x: x[1])
    df["specified_vehicle_count"] = pd.to_numeric(df["specified_vehicle_count"], errors="coerce")
    df["specified_vehicle_count"] = df["specified_vehicle_count"].fillna(df["recommended_vehicle_count"]).astype(int)
    # 日期与派生特征
    df["pickup_date"] = pd.to_datetime(df["pickup_date"])
    df["due_date"] = pd.to_datetime(df["due_date"])
    df["time_limit_days"] = (df["due_date"] - df["pickup_date"]).dt.days
    df["one_way_km"] = df["destination"].map(lambda c: road_distance(ORIGIN, CITY_COORDS[c]))
    df["round_trip_km"] = df["one_way_km"] * 1.86
    df["urgency_index"] = 1 / df["time_limit_days"].clip(lower=1)
    df["pallet_band"] = pd.cut(df["pallets"], [-np.inf, 1, 4, 8, 14, 18, np.inf],
                               labels=["微量", "小批", "中批", "4.2m满载段", "7.6m满载段", "超18托"])
    after = {
        "rows": len(df), "duplicates": int(df.duplicated(subset=["order_id"]).sum()),
        "missing": int(df.isna().sum().sum()), "corrected_outliers": int(outlier_mask.sum()),
        "date_errors": int((df["due_date"] < df["pickup_date"]).sum()),
    }
    report = {"before": before, "after": after}
    return df.sort_values("pickup_date").reset_index(drop=True), report


def generate_problem3_raw(n: int = 2400) -> pd.DataFrame:
    nodes = list(TERMINAL_NODES)
    # 本地节点权重更高
    weights = np.array([3 if n.startswith(("崇川", "港闸", "通州", "海门", "启东", "如皋", "海安", "如东")) else 1 for n in nodes], dtype=float)
    weights /= weights.sum()
    base = datetime(2026, 7, 6)
    rows = []
    for i in range(n):
        node = RNG.choice(nodes, p=weights)
        day = int(RNG.integers(0, 7))
        order_date = base + timedelta(days=day)
        cold = RNG.random() < 0.28
        service = RNG.choice(["送货", "当日达", "次日达", "3日达"], p=[0.45, 0.12, 0.30, 0.13])
        node_distance = road_distance(ORIGIN, TERMINAL_NODES[node])
        # 时限与地理距离保持业务可行:远距离终端不生成不现实的当日达要求
        if node_distance > 220 and service == "当日达":
            service = "次日达"
        if node_distance > 450 and service in {"当日达", "次日达"}:
            service = "3日达"
        if service == "当日达": due_days = 0
        elif service == "次日达": due_days = 1
        elif service == "3日达": due_days = 3
        else: due_days = int(RNG.choice([3, 4, 5, 6], p=[0.35, 0.35, 0.20, 0.10]))
        pallets = float(np.clip(RNG.gamma(1.25, 0.42), 0.02, 3.2))
        cases = max(1, int(round(pallets * RNG.uniform(35, 60))))
        rows.append({
            "order_id": f"TM{i+1:05d}", "order_date": order_date.date(), "ship_day": day,
            "destination": node, "lat": TERMINAL_NODES[node][0], "lon": TERMINAL_NODES[node][1],
            "temperature": "冷链" if cold else "常温", "service_level": service,
            "due_date": (order_date + timedelta(days=due_days)).date(), "pallets": pallets,
            "cases": cases, "linehaul_or_city": "市内" if road_distance(ORIGIN, TERMINAL_NODES[node]) < 130 else "干线",
        })
    df = pd.DataFrame(rows)
    raw = pd.concat([df, df.iloc[:20]], ignore_index=True)
    # 制造少量缺失、单位错误和地址空格
    miss_idx = RNG.choice(raw.index, size=25, replace=False)
    raw.loc[miss_idx[:12], "pallets"] = np.nan
    raw.loc[miss_idx[12:18], "temperature"] = np.nan
    raw.loc[miss_idx[18:], "destination"] = " " + raw.loc[miss_idx[18:], "destination"].astype(str) + " "
    raw.loc[RNG.choice(raw.index, size=6, replace=False), "pallets"] = -1
    return raw


def preprocess_problem3(raw: pd.DataFrame) -> Tuple[pd.DataFrame, Dict[str, object]]:
    before = {"rows": len(raw), "duplicates": int(raw.duplicated("order_id").sum()), "missing": int(raw.isna().sum().sum())}
    df = raw.drop_duplicates("order_id", keep="first").copy()
    df["destination"] = df["destination"].astype(str).str.strip()
    # 运输温度缺失采用"保守冷链"原则填充
    df["temperature"] = df["temperature"].fillna("冷链")
    # 托盘缺失/错误根据箱数和样本中位箱托比修复
    ratio = (df.loc[df["pallets"] > 0, "cases"] / df.loc[df["pallets"] > 0, "pallets"]).median()
    inferred = df["cases"] / ratio
    invalid = df["pallets"].isna() | (df["pallets"] <= 0) | (df["pallets"] > 5)
    df.loc[invalid, "pallets"] = inferred[invalid]
    df["order_date"] = pd.to_datetime(df["order_date"])
    df["due_date"] = pd.to_datetime(df["due_date"])
    df["distance_km"] = df["destination"].map(lambda n: road_distance(ORIGIN, TERMINAL_NODES[n]))
    df["region"] = df["destination"].str.split("-").str[0]
    df["urgency"] = ((df["due_date"] - df["order_date"]).dt.days + 1).rpow(-1)
    after = {"rows": len(df), "duplicates": int(df.duplicated("order_id").sum()), "missing": int(df.isna().sum().sum()), "corrected": int(invalid.sum())}
    return df.sort_values(["order_date", "destination"]).reset_index(drop=True), {"before": before, "after": after}

# -----------------------------
# 问题1:运输总费用核算
# -----------------------------
def solve_problem1(orders: pd.DataFrame) -> Tuple[pd.DataFrame, Dict[str, float]]:
    rows = []
    for _, r in orders.iterrows():
        vehicle = r["specified_vehicle"] if r["specified_vehicle"] in VEHICLES else r["recommended_vehicle"]
        n = int(r["specified_vehicle_count"])
        cap = VEHICLES[vehicle].capacity * n
        distance = float(r["round_trip_km"])
        cost = vehicle_cost(vehicle, distance, n=n, temperature="冷链")
        rows.append({
            **r.to_dict(), "calc_vehicle": vehicle, "calc_vehicle_count": n,
            "available_capacity": cap, "load_factor": float(r["pallets"]) / cap,
            "transport_cost": cost, "cost_per_pallet": cost / max(float(r["pallets"]), 0.01),
            "cost_per_pallet_km": cost / max(float(r["pallets"]) * distance, 0.01),
            "fixed_component": n * (VEHICLES[vehicle].fixed_cost_per_km * distance * 1.10 + VEHICLES[vehicle].dispatch_fee),
            "variable_component": n * VEHICLES[vehicle].variable_cost_per_km * distance * 1.10,
        })
    result = pd.DataFrame(rows)
    metrics = {
        "total_cost": result["transport_cost"].sum(),
        "total_orders": len(result), "total_vehicle_dispatches": int(result["calc_vehicle_count"].sum()),
        "total_round_trip_km": float((result["round_trip_km"] * result["calc_vehicle_count"]).sum()),
        "weighted_load_factor": float((result["pallets"].sum()) / (result["available_capacity"].sum())),
        "avg_cost_per_order": result["transport_cost"].mean(),
        "median_cost_per_pallet": result["cost_per_pallet"].median(),
    }
    return result, metrics

# -----------------------------
# 问题2:拼单运输与车辆调度
# -----------------------------
def explode_large_orders(orders: pd.DataFrame) -> pd.DataFrame:
    rows = []
    for _, r in orders.iterrows():
        remaining = float(r["pallets"])
        part = 1
        while remaining > 1e-9:
            qty = min(18.0, remaining)
            d = r.to_dict()
            d["parent_order_id"] = r["order_id"]
            d["order_id"] = f"{r['order_id']}-{part}"
            d["pallets"] = qty
            rows.append(d)
            remaining -= qty
            part += 1
    return pd.DataFrame(rows)


def bin_pack_group(group: pd.DataFrame, max_stops: int = 3, capacity: float = 18.0) -> List[List[int]]:
    """首次适应递减:兼顾容量与最多停靠数。"""
    idxs = list(group.sort_values(["pallets", "due_date"], ascending=[False, True]).index)
    bins: List[List[int]] = []
    loads: List[float] = []
    stop_sets: List[set] = []
    for idx in idxs:
        row = group.loc[idx]
        best = None
        best_slack = float("inf")
        for b, ids in enumerate(bins):
            new_stops = stop_sets[b] | {row["destination"]}
            if loads[b] + row["pallets"] <= capacity + 1e-9 and len(new_stops) <= max_stops:
                slack = capacity - loads[b] - row["pallets"]
                if slack < best_slack:
                    best, best_slack = b, slack
        if best is None:
            bins.append([idx]); loads.append(float(row["pallets"])); stop_sets.append({row["destination"]})
        else:
            bins[best].append(idx); loads[best] += float(row["pallets"]); stop_sets[best].add(row["destination"])
    return bins


def build_trip_from_indices(df: pd.DataFrame, indices: List[int], trip_id: str, source: str) -> Trip:
    rows = df.loc[indices].copy()
    load = float(rows["pallets"].sum())
    vehicle, n = choose_vehicle(load)
    if n > 1:
        raise ValueError("bin capacity should prevent multi-vehicle trip")
    nodes = nearest_neighbor_route(rows["destination"].tolist(), CITY_COORDS)
    nodes = two_opt(nodes, CITY_COORDS)
    departure, completions, feasible = schedule_route(rows, nodes, CITY_COORDS)
    dist = route_distance(nodes, CITY_COORDS) * 0.93  # 空载返程折减,与问题一1.86倍单程口径保持一致
    cost = vehicle_cost(vehicle, dist, 1, "冷链")
    return Trip(trip_id, rows["order_id"].tolist(), nodes, vehicle, "冷链", load, VEHICLES[vehicle].capacity,
                dist, cost, departure, completions, feasible, source)


def rolling_horizon_schedule(chunks: pd.DataFrame) -> List[Trip]:
    trips: List[Trip] = []
    tid = 1
    # 仅按当天可见订单拼单;按走廊划分,避免明显反向绕行
    for (day, corridor), g in chunks.groupby([chunks["pickup_date"].dt.date, "corridor"]):
        for ids in bin_pack_group(g, max_stops=2, capacity=18.0):
            trip = build_trip_from_indices(chunks, ids, f"RH{tid:03d}", "滚动短视")
            if not trip.feasible and len(ids) > 1:
                # 时间窗不可行则拆分
                for idx in ids:
                    trips.append(build_trip_from_indices(chunks, [idx], f"RH{tid:03d}", "滚动短视-拆分")); tid += 1
            else:
                trips.append(trip); tid += 1
    return trips


def advanced_week_schedule(chunks: pd.DataFrame) -> Tuple[List[Trip], List[float]]:
    trips: List[Trip] = []
    tid = 1
    # 以自然周和走廊为分解单元,提前一周将相容订单联合装载
    temp = chunks.copy()
    temp["week"] = ((temp["pickup_date"] - temp["pickup_date"].min()).dt.days // 7).astype(int)
    for (week, corridor), g in temp.groupby(["week", "corridor"]):
        # 时间窗相容性:到期日相差不超过3天
        g = g.sort_values("due_date")
        groups = []
        current = []
        current_min_due = None
        for idx, row in g.iterrows():
            if current_min_due is None or abs((row["due_date"] - current_min_due).days) <= 3:
                current.append(idx)
                current_min_due = row["due_date"] if current_min_due is None else min(current_min_due, row["due_date"])
            else:
                groups.append(current); current = [idx]; current_min_due = row["due_date"]
        if current:
            groups.append(current)
        for sub in groups:
            sg = temp.loc[sub]
            for ids in bin_pack_group(sg, max_stops=3, capacity=18.0):
                trip = build_trip_from_indices(temp, ids, f"WK{tid:03d}", "一周预知")
                if not trip.feasible and len(ids) > 1:
                    # 将最早到期订单拆出,保证硬时间窗
                    sorted_ids = sorted(ids, key=lambda i: temp.loc[i, "due_date"])
                    first = sorted_ids[0]
                    rest = sorted_ids[1:]
                    trips.append(build_trip_from_indices(temp, [first], f"WK{tid:03d}", "一周预知-时间窗修复")); tid += 1
                    if rest:
                        trips.append(build_trip_from_indices(temp, rest, f"WK{tid:03d}", "一周预知-时间窗修复")); tid += 1
                else:
                    trips.append(trip); tid += 1

    # 轻量自适应局部搜索:同走廊、同周的单订单旅行并入有余量旅行
    # 为保证脚本透明可复现,以确定性最优改进为主,并记录"收敛曲线"。
    curve = [sum(t.cost for t in trips)]
    for it in range(80):
        improved = False
        # 按成本降序尝试把小旅行订单移入其他旅行
        singles = [t for t in trips if len(t.order_ids) == 1]
        for s in sorted(singles, key=lambda x: x.load):
            oid = s.order_ids[0]
            row = temp[temp["order_id"] == oid].iloc[0]
            candidates = []
            for j, t in enumerate(trips):
                if t.trip_id == s.trip_id or t.load + s.load > 18 + 1e-9:
                    continue
                trows = temp[temp["order_id"].isin(t.order_ids)]
                if trows.empty or trows["corridor"].iloc[0] != row["corridor"]:
                    continue
                if len(set(t.route_nodes) | {row["destination"]}) > 3:
                    continue
                new_ids = t.order_ids + [oid]
                new_indices = temp[temp["order_id"].isin(new_ids)].index.tolist()
                new_trip = build_trip_from_indices(temp, new_indices, t.trip_id, "一周预知-局部搜索")
                if new_trip.feasible:
                    delta = new_trip.cost - t.cost - s.cost
                    candidates.append((delta, j, new_trip))
            if candidates:
                delta, j, new_trip = min(candidates, key=lambda x: x[0])
                if delta < -1e-6:
                    s_index = next(k for k, t in enumerate(trips) if t.trip_id == s.trip_id)
                    # 先替换目标,再删除单旅行,注意索引变化
                    target_id = trips[j].trip_id
                    trips[j] = new_trip
                    trips = [t for t in trips if t.trip_id != s.trip_id]
                    improved = True
                    break
        curve.append(sum(t.cost for t in trips))
        if not improved:
            # 仍填充曲线,体现稳定收敛
            curve.extend([curve[-1]] * (80 - it - 1))
            break
    return trips, curve


def trips_to_df(trips: List[Trip]) -> pd.DataFrame:
    return pd.DataFrame([{
        "trip_id": t.trip_id, "order_count": len(t.order_ids), "order_ids": ";".join(t.order_ids),
        "route": "→".join([ORIGIN_NAME] + t.route_nodes + [ORIGIN_NAME]), "stops": len(t.route_nodes),
        "vehicle": t.vehicle, "temperature": t.temperature, "load": t.load, "capacity": t.capacity,
        "load_factor": t.load_factor, "distance_km": t.distance_km, "cost": t.cost,
        "departure": t.departure, "feasible": t.feasible, "source": t.route_source,
    } for t in trips])


def summarize_trip_plan(df: pd.DataFrame) -> Dict[str, float]:
    return {
        "trips": int(len(df)), "total_cost": float(df["cost"].sum()), "total_distance": float(df["distance_km"].sum()),
        "mean_load_factor": float(df["load_factor"].mean()), "weighted_load_factor": float(df["load"].sum() / df["capacity"].sum()),
        "feasible_rate": float(df["feasible"].mean()), "multi_order_rate": float((df["order_count"] > 1).mean()),
    }

# -----------------------------
# 问题3:派车合理性评价与优化
# -----------------------------
def simulate_actual_dispatch(orders: pd.DataFrame) -> pd.DataFrame:
    trips = []
    tid = 1
    # 现行方案:按日、区域粗分后随机小批派车,刻意保留真实企业中常见的低装载和不必要冷链现象
    for (day, region), g in orders.groupby(["ship_day", "region"]):
        idxs = list(g.index)
        RNG.shuffle(idxs)
        pos = 0
        while pos < len(idxs):
            group_size = int(RNG.choice([1, 2, 3, 4, 5], p=[0.30, 0.28, 0.22, 0.14, 0.06]))
            ids = idxs[pos:pos + group_size]
            pos += group_size
            rows = orders.loc[ids]
            load = float(rows["pallets"].sum())
            # 若超过容量再截断,以便形成合理派车记录
            if load > 18:
                ids = [ids[0]]
                rows = orders.loc[ids]
                load = float(rows["pallets"].sum())
            min_vehicle, _ = choose_vehicle(load)
            # 45%概率选大一档车型
            vehicle_order = ["4.2m", "7.6m", "9.6m"]
            vi = vehicle_order.index(min_vehicle)
            if RNG.random() < 0.45 and vi < 2:
                vehicle = vehicle_order[vi + 1]
            else:
                vehicle = min_vehicle
            need_cold = (rows["temperature"] == "冷链").any()
            if need_cold:
                temperature = "冷链"
            else:
                temperature = "冷链" if RNG.random() < 0.24 else "常温"
            nodes = rows["destination"].tolist()
            # 实际路线部分按录入顺序,产生一定绕行
            if RNG.random() < 0.65:
                route = list(dict.fromkeys(nodes))
            else:
                route = nearest_neighbor_route(nodes, TERMINAL_NODES)
            dist = route_distance(route, TERMINAL_NODES, factor=1.16)
            # 实际绕行、等待、空驶修正
            detour = float(np.clip(RNG.normal(1.12, 0.09), 1.0, 1.42))
            dist *= detour
            cost = vehicle_cost(vehicle, dist, 1, temperature)
            depart = pd.to_datetime(rows["order_date"]).min().to_pydatetime().replace(hour=9)
            max_due = pd.to_datetime(rows["due_date"]).max().to_pydatetime().replace(hour=17)
            travel_hours = dist / 45 + 1.2 * len(route)
            finish = depart + timedelta(hours=travel_hours)
            # 约3%人为延误
            if RNG.random() < 0.03:
                finish += timedelta(days=2)
            on_time = all(finish <= pd.to_datetime(d).to_pydatetime().replace(hour=17) for d in rows["due_date"])
            cold_compliance = not need_cold or temperature == "冷链"
            direct_min = max(1.0, 2 * max(rows["distance_km"].max(), 1.0))
            trips.append({
                "trip_id": f"AC{tid:04d}", "order_ids": ";".join(rows["order_id"]), "order_count": len(rows),
                "ship_day": day, "region": region, "route": "→".join([ORIGIN_NAME] + route + [ORIGIN_NAME]),
                "vehicle": vehicle, "temperature": temperature, "need_cold": need_cold,
                "load": load, "capacity": VEHICLES[vehicle].capacity, "load_factor": load / VEHICLES[vehicle].capacity,
                "distance_km": dist, "detour_ratio": dist / direct_min, "cost": cost, "finish": finish,
                "on_time": on_time, "cold_compliance": cold_compliance,
                "unnecessary_cold": (not need_cold and temperature == "冷链"),
            })
            tid += 1
    return pd.DataFrame(trips)
全部论文请见下方" 只会建模 QQ名片" 点击QQ名片即可
相关推荐
不会代码的小猴2 小时前
21. 泛型编程上
开发语言·c++·笔记·算法
AI备案指南-满满3 小时前
大模型与算法备案全流程详解:从零到通过的完整指南
人工智能·算法·备案·大模型备案·算法备案
江畔柳前堤3 小时前
LLM 训练核心机制深度解析:Warmup、Cosine Decay 与 Perplexity 的完整知识体系
网络·人工智能·深度学习·算法·机器学习·语音识别
一只旭宝3 小时前
细讲C加加【9】C++ std::function与std::bind详解|仿函数、绑定器、类成员绑定、占位符、成员偏移指针
开发语言·c++·算法
良木林3 小时前
子串 - LeetCode hot 100
算法·leetcode·职场和发展
陌诺曦.4 小时前
Python扩展小练习
算法
coder!mq5 小时前
说几个常见的语法糖?
java·开发语言·算法
不爱学英文的码字机器6 小时前
推荐算法梳理,六种主流模型与九步训练流程
算法·机器学习·推荐算法