搞过论文复现的人都知道,拿到一篇EI论文的题目,尤其是带“研究”两个字的,第一反应都不是兴奋,而是先发愁——这玩意到底能不能复现出来?数据哪来?模型怎么搭?程序跑不跑得通?特别是售电市场这种跟电改政策绑得很紧的方向,文字描述往往一大堆,真正落到Matlab代码上,能直接被卡死的细节却一个都不少。
我最近刚好完整复现了“售电市场环境下电力用户选择售电公司行为研究”这个课题,从读文献、拆模型到写代码、跑仿真、调参数,整个过程踩了不少坑,也整理出一套比较顺的复现思路。这篇文章就围绕这个EI复现项目,把研究方法、数学模型、Matlab实现和排查经验一次性讲透,给要做电力市场选题、用户行为仿真的同学一个可以直接上手的参考。
先说这个课题是干什么的:售电侧放开之后,电力用户不再只能从电网公司买电,而是可以在多家售电公司之间做选择。用户的这个选择行为,直接影响售电公司的市场份额和定价策略,是售电市场研究里很核心的问题。复现的目标,就是把“用户怎么选售电公司”这件事建一个数学模型,然后用Matlab仿真不同场景下的选择结果,输出可分析的图表和结论。
1. 这个复现项目到底在研究什么
1.1 售电侧放开给用户带来的选择权
以前电力市场是高度一体化的,发输配售一条链,用户没有选择权,电价也是政府定好的。但售电侧改革之后,售电环节被独立出来,社会上出现了大量售电公司,用户可以自由选择从哪家售电公司购电。
这里面的关键点在于:用户的“自由选择”不是凭感觉,而是一个典型的决策过程。用户会考虑电价高低、服务质量、本公司信誉、电费结算方式、绿色能源占比等因素。不同用户的偏好不一样,有的对大用户来说电价降一厘钱都是大事,有的商业用户则更看重服务响应速度。这些偏好差异,在学术上就可以建模成用户对售电公司各项属性的效用评价。
复现这个课题,本质上就是把现实中这种复杂的、千人千面的决策过程,用数学语言表达出来,再用实验数据验证模型的解释能力。理解了这一点,整个复现的技术路线也就清楚了:先确定用户选择行为的理论模型,再设计多场景仿真,最后分析仿真结果。
1.2 复现论文要解决的关键问题
读原文的时候,重点关注的是论文用了什么模型框架、设置了哪些场景、得出了什么核心结论。我把复现工作拆成了四个核心问题:
一是用户选择行为的理论刻画方式。不同论文会采用不同方法,比如层次分析法、逼近理想解排序(TOPSIS)、演化博弈、离散选择模型等。我复现的这篇EI论文采用的是基于随机效用理论的多项Logit(MNL)模型。
二是效用函数如何构建。用户选择售电公司时考虑哪些属性、每个属性权重怎么确定、效用函数的具体形式是什么。这些直接决定仿真结果是否合理。
三是仿真场景怎么设置。论文一般不会把所有参数都写全,但会根据研究问题设计若干对比场景来描述不同市场环境,比如售电公司定价策略变化、用户偏好结构变化、市场成熟度不同等。
四是需要输出什么样的结果。通常包括用户选择概率分布、售电公司市场份额变化曲线、关键参数的敏感性分析等。最终就是要用图表复现这些结果,验证并支撑对论文的理解甚至在此基础上继续做扩展。
2. 方法选型与数学模型拆解
2.1 为什么选多项Logit模型
在电力用户选择售电公司的研究里,多项Logit模型(Multinomial Logit Model)几乎是“性价比最高”的选择,也是这类论文最常用的方法之一。
先说直觉。用户面对若干个售电公司,本质上是在多个选项里挑一个最“合算”的。但用户的偏好里有太多我们观察不到的因素,比如主观好恶、信息渠道差异、历史使用习惯等。如果把这部分全部归入一个随机误差项,那用户选择某个售电公司的概率,就由一个可观测的效用(如电价、服务质量等带来的效用)和一个随机项共同决定。
MNL模型的数学形式非常优雅:假设用户i选择售电公司j的效用为Uij,我们只观察到其中的确定性部分Vij,随机部分服从极值分布,那么选择概率可以写成:
[ P_{ij} = \frac{\exp(V_{ij})}{\sum_{k=1}^{J} \exp(V_{ik})} ]
这里J是可选售电公司的数量。这个公式的意思是:用户选择的概率,跟该选项的效用指数成正比,跟所有选项效用指数之和相比。
跟其他方法比,MNL最大的优势是既有微观经济学理论支撑,又简单可算,而且在Matlab里实现也就是一个softmax事。相比之下,层次分析法和TOPSIS都需要人为打分赋权,主观性太强,不太好做情景对比;演化博弈则偏向群体行为演化,刻画个体异质性相对弱一些。MNL刚好卡在“理论深度”和“工程可实现性”的平衡点上,适合做论文复现。
2.2 效用函数怎么构建
确定用MNL之后,最重要的工作就是构建效用函数Vij。这部分是复现工作的核心,也是原文写得最详细、最需要逐句抠的部分。
在我复现的这篇论文里,用户选择售电公司主要考虑四个属性:
- 售电公司的售电价格(元/千瓦时)
- 服务质量水平(用服务响应时间或服务评分表示)
- 公司信誉与品牌影响力(用信誉评分表示)
- 绿色电力比例(用绿电占比表示)
这里有个重要细节:这些属性量纲不同,不能直接相加,必须先做归一化处理。论文里采用的是极差标准化,把每个属性值映射到[0,1]区间。我复现时用Matlab自带的mapminmax函数就能实现。
归一化之后,效用函数的形式是:
[ V_{ij} = \alpha_1 \cdot P_{ij}^* + \alpha_2 \cdot S_{ij}^* + \alpha_3 \cdot C_{ij}^* + \alpha_4 \cdot G_{ij}^* ]
其中P是价格(取负,因为价格越高效用越低),S是服务质量,C是公司信誉,G是绿色电力占比,上标*表示归一化后的值。α是各属性的权重系数,代表用户对某个属性的重视程度。原文通过问卷调查或预设偏好场景给定了权重,复现时就要做参数敏感性分析,看权重变化如何影响用户选择结果。
这里我踩过一个坑,就是市场价格属性取负的问题。如果直接套原始价格数值进效用函数,价格越高效用反而越高,仿真出来的选择概率完全反了。一定要把价格属性构造成“价格越低越好”的形式,我用的是负的价格归一化值。如果是跟成本、价格相关的属性,先想清楚“大还是小更受偏好”,再决定是不是取负。
2.3 仿真场景设计
数学模型确定之后,就要设计仿真场景了。原文的仿真思路基本可以分成三类场景:
一是基准场景。把售电公司的各项属性设置为某个合理的基准值,模拟市场处于均衡状态时用户的选择分布。这个场景作为对照,检验模型能否正常运转。
二是政策或市场变动场景。比如某家售电公司主动降低售电价,或者提升绿电比例,观察用户选择概率怎么变化,从而分析售电公司的竞争策略效果。
三是用户偏好差异场景。把用户分为价格敏感型、服务敏感型、绿电偏好型等群体,赋予不同的权重向量,观察不同类型用户的选择差异。
仿真场景设置的逻辑,是要能突出研究问题、对比不同情形下的结果差异。复现的时候,论文给出的场景参数往往不完整,需要根据论文图表里的坐标范围反推合理参数。比如原文画了“售电价格从0.4元/千瓦时变化到0.6元/千瓦时市场份额变化曲线”,那场景参数就设定在这个区间内逐步扫描。
3. Matlab代码复现全流程
3.1 代码整体框架与文件分工
复现一个带Matlab代码的研究项目,最忌讳的是把所有逻辑堆在一个脚本里。我建议按照模块拆分,这样排查错误、调整参数、做敏感性分析都会顺手很多。
我复现时的代码结构是这样安排的:
| 文件/函数 | 功能 |
|---|---|
main.m | 主程序:初始化参数、调用各模块、汇总结果 |
init_params.m | 初始化所有仿真参数的配置文件 |
gen_users.m | 生成用户群体,设置用户偏好权重、属性值等 |
calc_utility.m | 计算用户对每个售电公司的效用值 |
calc_prob.m | 基于多项Logit模型计算选择概率 |
plot_share.m | 可视化用户选择结果和市场份额 |
main.m的核心结构是这样一个流程:初始化参数→生成用户→计算效用→计算选择概率→汇总统计→绘图。
%% 初始化 params = init_params(); %% 对每个场景进行仿真 for s = 1:length(params.scenarios) % 生成用户群体 users = gen_users(params, s); % 计算每个用户对每个售电公司的效用 U = calc_utility(users, params.retailers, params.weights); % 计算选择概率 P = calc_prob(U, params.theta); % 统计市场份额 share(s, :) = mean(P, 1); end %% 绘图 plot_share(share, params.scenario_names);这个框架看起来简单,但它把“仿真参数”“用户生成”“效用计算”“概率计算”四个关注点分开了。后面调权重、换场景、加属性,都不用动主程序,只改对应函数就行。
3.2 用户群体与场景参数生成
用户生成这一步,核心是模拟用户的异质性。现实中不同用户对价格、服务、绿电的敏感度不一样,建模上就是给每个用户分配不同的权重向量。
function users = gen_users(params, scenario_id) rng(scenario_id * 42); % 固定随机种子,保证结果可复现 n = params.num_users; % 用户偏好权重矩阵:每行是一个用户对四项属性的权重 % 这里采用正态分布生成,模拟偏好差异 users.weights = abs(randn(n, 4)) + 0.1; % 归一化到和为1 users.weights = users.weights ./ sum(users.weights, 2); % 不同场景可以调整均值偏移 if scenario_id == 2 % 比如场景2是价格敏感型市场 users.weights(:,1) = users.weights(:,1) * 2; users.weights = users.weights ./ sum(users.weights, 2); end end这里用randn是为了让用户群体的偏好有差异,而不是所有人都同一个权重。加0.1是避免某个权重为0导致用户完全不关注某个属性——这在现实中几乎不可能,但在仿真里会导致矩阵奇异或者概率为0,很麻烦。
售电公司属性的设定也很关键。我一般用一个结构体数组来存每家售电公司的各项属性值:
params.retailers = struct(... 'name', {'公司A', '公司B', '公司C'}, ... 'price', [0.52, 0.48, 0.55], ... % 单位元/kWh 'service', [7.5, 8.2, 6.9], ... % 服务评分(满分10) 'credibility', [8.0, 7.0, 8.8], ... % 信誉评分 'green_ratio', [0.20, 0.35, 0.15]); % 绿电比例3.3 核心选择概率计算逻辑
选择概率计算是全部代码的心脏。前面公式里的MNL概率,在Matlab里可以直接用向量化的方式实现,不需要写成for循环遍历每个用户,这样在用户量比较大的时候(比如1万个用户)也能跑得很快。
function P = calc_prob(U, theta) % U: 用户×售电公司的效用矩阵 % theta: 效用规模参数,相当于logit模型里的标度参数 expU = exp(theta * U); % 分母:每个用户的效用指数之和(沿着公司维度求和) denom = sum(expU, 2); P = expU ./ denom; end注意一个细节:如果用分号的写法还是不行,别忽略theta这个参数。theta在MNL模型里控制了效用差异的选择敏感性,theta越大用户越倾向于选择效用最高的售电公司,theta为0时用户完全随机选择,概率等于1/公司数。我在复现时最开始把theta设为1,结果仿真出来用户选择行为跟没头苍蝇一样,后来调成5才出现明显的选择差异。
计算效用矩阵U的时候,另一种常见写法是:
function U = calc_utility(users, retailers, ~) % 提取属性矩阵:每一行是不同公司,列是属性 attr_price = -normalize([retailers.price]); % 负值,价格越低越好 attr_service = normalize([retailers.service]); attr_cred = normalize([retailers.credibility]); attr_green = normalize([retailers.green_ratio]); % 属性矩阵 attr_mat = [attr_price; attr_service; attr_cred; attr_green]'; % 每个用户对每个公司的效用 = 公司属性 × 用户权重向量 % users.weights是 n×4,attr_mat是 J×4 % 用矩阵乘法批量计算:U(i,j) = weights(i,:) * attr_mat(j,:)' U = users.weights * attr_mat'; end这里面有个易错点:用户权重矩阵是n行4列,公司属性矩阵是J行4列,直接乘法会维度不匹配。我上面用的users.weights * attr_mat',得到的是n行J列,正好每一行是某个用户对所有售电公司的效用。想清楚矩阵维度,就能避免一大堆低级报错。
3.4 可视化与结果解读
仿真的结果最终要靠图表呈现。Matlab绘图的核心是能清晰表达“不同场景/不同参数下的选择概率或市场份额变化”。
最常用的图是不同售电公司的市场份额随某个参数变化的曲线。这个图的画法其实很简单:对一个参数做扫描,每个参数点跑一次仿真,把市场份额记录下来,最后plot出来。
% 对售电价格从0.40到0.60进行扫描 price_range = 0.40:0.01:0.60; shares = zeros(3, length(price_range)); for k = 1:length(price_range) params.retailers(1).price = price_range(k); U = calc_utility(gen_users(params, 1), params.retailers, []); P = calc_prob(U, params.theta); shares(:, k) = mean(P, 1); end figure; plot(price_range, shares(1,:), 'r-o', 'LineWidth', 1.5); hold on; plot(price_range, shares(2,:), 'b--s', 'LineWidth', 1.5); plot(price_range, shares(3,:), 'g-.^', 'LineWidth', 1.5); grid on; xlabel('公司A的售电价格(元/kWh)'); ylabel('市场份额'); legend({'公司A','公司B','公司C'}, 'Location', 'best');画这种图的时候有个经验:线条类型一定要区分清楚(实线、虚线、点划线、不同的marker),否则打印出来或者放在论文里变成灰度图之后,没法区分是哪家公司。另外,xlabel和ylabel一定要带单位,这是学术论文复现的基本素养。
除了曲线图,还可以用堆叠柱状图表示不同用户群体对售电公司的选择结构,用热力图展示不同权重组合下的选择概率分布。可视化本身不是目的,核心是能从图里读出研究结论。我画完图之后,都会检查每个图的曲线趋势是否符合直觉,比如价格下降市场份额上升,如果出现反向趋势,一定是代码有bug。
4. 复现过程中的踩坑记录与排查手册
4.1 典型错误和卡点
整个复现过程里我踩了不少坑,其中几个特别有代表性。
第一个坑是随机数的复现问题。Matlab里如果每次运行都调用rand和randn,没有设置随机种子,每次跑出来的结果都不一样。论文里你写“固定随机种子可以复现结果”,代码就一定要有rng固定种子的语句。我一开始没注意这件事,调好参数之后重新跑一遍,发现曲线变了,还以为程序出问题了,折腾了很久才发现是随机种子没固定。
第二个坑是效用函数的符号方向。前面提过价格属性取负的问题,这个坑我印象太深了。最开始我把归一化后的价格直接加权进效用,跑出来的结果是“价格越贵份额越高”,一开始以为模型反了,后来仔细一想,正确做法是对价格取负再参与计算。也就是说,效用函数里的价格系数理论上应该是负值,用最小值归一化后价格最低的公司会得到效用最高。
第三个坑是theta设置不当导致概率几乎均匀分布。很多MNL复现的教程里不会强调theta的重要性,但对于读代码的人来说,直接套用论文公式是看不出这个问题的。实际仿真时如果theta太小(如0.1),所有概率都接近1/3,看不出变化趋势;如果theta太大(如100),概率分布又过于极端,几乎变成确定性选择。建议theta在1到10之间多试几个值,选出效果最合理的。
第四个坑是用户数量太少导致统计波动大。如果用户只生成50个,选择概率算出来是跳跃的,曲线的毛刺非常多。我后来把用户量加到2000以上,市场占有率的曲线才比较平滑。Matlab里计算2000用户的选择概率也就是矩阵运算一瞬间的事,没必要用太少用户数。
4.2 常见错误速查表
结合复现过程,我整理了一张常见错误排查表,对新手特别有用:
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| 运行报错“矩阵维度不一致” | 用户权重矩阵与公司属性矩阵维度不匹配 | 统一属性数量,检查是否多算或少算了一列 |
| 市场份额出现NaN或Inf | 效用值过大,exp()溢出 | 检查属性归一化是否做对,theta是否调得过大 |
| 所有概率几乎相同 | theta值太小,用户偏好区分度不够 | 增大theta,或增加用户群体权重差异 |
| 市场份额曲线毛刺多 | 用户数量太少 | 增加用户数量到2000以上 |
| 价格越贵份额越高 | 价格属性没取负 | 价格类属性取负后再加入效用函数 |
| 每次运行结果不一样 | 没有设置随机种子 | 在main.m开头加rng(固定数值) |
| 不同场景结果无法对比 | 场景间用户生成没有固定对应关系 | 给每个场景分配不同的随机种子,保证场景间可区分但可重复 |
4.3 如何判断复现结果是否可靠
判断复现结果是否可靠,我的经验是从三个层次来检查。
第一层是检查数值范围是否合理。选择概率应该在0到1之间,而且每一行的和应该等于1。如果一个用户对三家公司的概率加和不是1,那计算概率的代码一定有bug。这个检查我用一行代码就搞定:assert(all(abs(sum(P,2) - 1) < 1e-8))。
第二层是检查单调性是否符合经济学直觉。降价的售电公司市场份额应该上升;绿电比例升高的公司,在绿电偏好型用户群体中的选择概率应该明显增加。如果这些直觉关系不成立,说明效用函数或者权重设置有误。
第三层是检查结论是否匹配原文。EI论文复现不是要“完全数字一致”——因为论文里很多随机种子、具体参数是不公开的,但要保证趋势和核心结论一致。比如原文说“价格因素对用户选择影响显著”,那复现结果应该体现价格变化引起市场份额较大波动。如果趋势方向一致,数值上有合理差异,这个复现就是成功的。
5. 个人实操经验与扩展建议
整个项目跑通之后,我最大的感受是:复现EI论文的价值不在于把论文的图和表原封不动地生成一遍,而在于通过写代码、调参数、看结果,真正理解每个模型假设背后的原因。比如多项Logit模型为什么用指数函数形式,为什么随机项假设为极值分布,这些在教科书上只是一行公式,但自己实现一遍之后,才会理解它对仿真的影响是什么。
给正在做类似课题的同学几个具体建议:
第一,不要上来就埋头写代码,先把论文的模型图和公式推导过一遍。论文里没有明确的参数,就标注出来,根据仿真结果反向推合理范围。
第二,Matlab代码的格式和注释,宁可多写不要少写。这不仅是说给其他人看,也是在复现结束后你会感谢自己当时的注释。这个项目做完隔两周再看代码,如果没有注释,真的会忘掉每一个矩阵的具体含义。
第三,参数配置文件一定要单独放。把仿真参数集中在一个init_params.m里,改参数的时候不需要到处翻代码,也能避免改了一个地方忘了另一个地方的尴尬。
第四,画图的时候把关键图表保存成高分辨率的图片格式,字体设置一致,方便后面直接用于论文写作。我常用exportgraphics(gcf, 'output.png', 'Resolution', 300),这样导出的图片放到Word里也是清晰的。
如果还想扩展这个项目,可以考虑往三个方向做:一是把MNL模型换成混合Logit模型,考虑用户偏好的随机系数,更细腻地刻画用户异质性;二是引入售电公司之间的博弈,比如两家公司同时调价时的市场均衡;三是结合真实用电数据,对用户群体进行聚类分析,再给每一类用户构建选择模型,这个跟热点词里大家常搜的kmeans聚类算法也能衔接上。最后一点点小技巧,也是我复现到后期才领悟的:务必用版本管理工具跟踪脚本的每次修改。自己在调参过程中会把好的参数试坏,回退版本比逐个手改要高效很多。哪怕不习惯用复杂的git操作,隔一段时间把当前能正常跑的脚本复制一份备份,在文件名上加上日期,也比最后找不到可用版本强得多。