概述
高考综合改革后,由于在考生选择的科目中不同学科试题难度差异和报考相应学科的考生群体不同,选考科目的原始分本身不具有直接可比性和可加性,因此需要进行赋分转换。
这个操作的具体过程如下:对于某特定科目,按照考生的原始分分布情况,将原始分从高到低依次按照15%、35%、35%、13%和2%的比例分别划分为A、B、C、D、E共5个等级,然后再按照等比例转换规则将原始分对应的相应等级分别转换到100~86,85~71,70~56,55~41和40~30五个分数区间,1分1档,赋分转换后的考生等级转换分数排序和原始分排序不变,转换后的等级分(赋分)计入最后的科目成绩。
笔者理解,这个等级分或者赋分的实际意义在于:
- 赋分基本上能够表达,原始分数先落入某一个级别的分数段,然后再按照排名或者分数体现在分数段中的比例关系
- 无论分数值如何分布,都可以转换成为一个相对固定的分数空间
- 保持排名相对关系不变
所以,这个赋分机制本质上就是将原始分转换成为科目中的排名所反映的分数,并进行归一化(相同比例),然后可以在不同的科目中进行比较(因为比的是无量纲的相对排名),也可以进行累加,为评估考生的综合学业水平,就提供了相对科学合理的依据。
下面,我们基于以上的思想和规则,实际在Postgres中进行操作和测试,帮助读者来更好的理解赋分制和相关操作的过程。
数据表结构
首先创建测试数据表结构如下:
sql
-- 赋分操作测试数据表
create table cdata (
id varchar primary key, -- 考生id
course int4 default 1, -- 科目
score int, -- 分数
slevel char(1), -- 赋分级别
fscore float, -- 赋分值
flag int4 -- 操作标识
);
这里基于精确计算的需求,赋分值使用单精度浮点数,如果需要转换为整数,简单取整即可。
测试数据生成
我们规划设计的测试数据,理想情况下,应该满足正态分布。因此,我们需要定义能够生成满足正态分布随机数的方法,并且在指定的数值范围内,应用这个方法,就可以在比较大的数据规模中,生成基本正态分布的数值。
sql
-- box muller变换,生成满足正态分布的随机数
CREATE OR REPLACE FUNCTION bm_transform()
RETURNS real AS $$
DECLARE
u1 real;
u2 real;
z0 real;
BEGIN
u1 := random();
u2 := random();
z0 := sqrt(-2.0 * ln(u1)) * cos(2.0 * pi() * u2);
RETURN z0;
END;
$$ LANGUAGE plpgsql;
-- 正态分布范围
CREATE OR REPLACE FUNCTION normal_int(imin int, imax int)
RETURNS integer AS $$
DECLARE
result integer;
mean real;
std_dev real;
BEGIN
mean = imin + (imax - imin) / 2;
std_dev = (imax - imin) / 6 ;
LOOP
result := round(mean::real + std_dev::real * bm_transform());
IF result BETWEEN imin AND imax THEN
RETURN result;
END IF;
END LOOP;
END;
$$ LANGUAGE plpgsql;
-- 生成和插入数,取值为25~100,插入50000数据
insert into cdata(id, score)
select g, normal_int(25,100) v from generate_series(10001,60000) G;
等级划分
使用分类规则,对数据进行排序和等级划分,规则如下:
- 分类操作的范围为数据库中,同一科目的所有成绩
- 将原始分从高到低排列
- 前15%,等级为A
- 然后35%,等级为B
- 然后35%,等级为C
- 然后13%,等级为D
- 最后2%,等级为E
具体操作如下:
sql
-- 划分级别
with P as (
select id pid, score, 100 - 100*(percent_rank() over (partition by course order by score desc)) rk from cdata
)
update cdata set slevel = (case when rk >=85 then 'A' when rk >= 50 then 'B' when rk >= 15 then 'C' when rk >= 2 then 'D' else 'E' end)
from P where pid = id;
UPDATE 58700
Time: 1271.704 ms (00:01.272)
-- 分布查询
select slevel, count(1) from cdata group by 1 order by 1;
slevel | count
--------+-------
A | 9196
B | 20809
C | 19940
D | 7696
E | 1059
(5 rows)
Time: 173.496 ms
笔者的测试系统上,实际执行数据为58700条,耗时1300ms。
百分比例算法
按照以下规则分等级进行分值的转换,按照排名的百分比,转换为赋分数值:
100~86,85~71,70~56,55~41和40~30五个分数区间,1分1档,
sql
-- 赋值转换, A,B,C,D,E...
with N as (
(select 'A' plevel, 86 as b, 14 as p, percent_rank() over (order by score desc) mrank from cdata where slevel = 'A' order by score limit 1)
union all
(select 'B', 71, 14 ,percent_rank() over (order by score desc) from cdata where slevel = 'B' order by score limit 1)
union all
(select 'C', 56, 14 as p,percent_rank() over (order by score desc) from cdata where slevel = 'C' order by score limit 1)
union all
(select 'D', 41, 14 as p,percent_rank() over (order by score desc) from cdata where slevel = 'D' order by score limit 1)
union all
(select 'E', 30, 10 as p,percent_rank() over (order by score desc) from cdata where slevel = 'E' order by score limit 1)
),
U as (
select id uid, score, b + p*(mrank - percent_rank() over (partition by slevel order by score desc)) / mrank uscore from cdata join N on slevel = plevel
)
update cdata set fscore = uscore from U where uid = id ;
UPDATE 58700
Time: 1722.723 ms (00:01.723)
-- 数据检查
select slevel, max(fscore), min(fscore) , avg(score), avg(fscore), count(1)
from cdata group by 1 order by 1;
slevel | max | min | avg | avg | count
--------+-----+-----+---------------------+--------------------+-------
A | 100 | 86 | 83.3216615919965202 | 92.66652229915722 | 9196
B | 85 | 71 | 67.8071988082079869 | 77.92079336985566 | 20809
C | 70 | 56 | 55.2344032096288867 | 63.209007405726986 | 19940
D | 55 | 41 | 40.9211278586278586 | 48.33687285807507 | 7696
E | 40 | 30 | 23.8611898016997167 | 35.46712672690425 | 1059
(5 rows)
Time: 224.371 ms
分数比例算法
除了按照分数在分组中的百分比作为比例进行分值转换之外,还可以使用分值作为比例的转换,这时候不用考虑排名问题。相应的实现参考代码如下:
sql
-- 复制基础数据到新表 cdata1
create table cdata1 as select * from cdata;
SELECT 58700
Time: 266.759 ms
-- 按分数作为比例更新赋分值
with
G as (select slevel, max(score)- min(score) gap, min(score) imin from cdata1 group by 1),
C as ( select slevel ll, imin, b, p::real/gap bl from G join (values ('A',86,14),('B',71,14),('C',56,14),('D',41,14),('E',30,10)) as C(l,b,p) on l = slevel)
update cdata1 set fscore = b + (score - imin) * bl from C where ll = slevel;
UPDATE 58700
Time: 331.646 ms
-- 检查数据
(select * from cdata where score = 60 limit 1)
union all
(select * from cdata1 where score = 60 limit 1);
id | score | slevel | fscore | flat | course
-------+-------+--------+-------------------+------+--------
13408 | 60 | C | 68.68127134226425 | | 1
13408 | 60 | C | 68.92307692307692 | | 1
(2 rows)
Time: 164.931 ms
-- 统计数据
select slevel, max(fscore), min(fscore) , avg(score), avg(fscore), count(1)
from cdata1 group by 1 order by 1;
slevel | max | min | avg | avg | count
--------+-----+-----+---------------------+--------------------+-------
A | 100 | 86 | 83.3216615919965202 | 90.27096926199886 | 9196
B | 85 | 71 | 67.8071988082079869 | 77.253906408842 | 20809
C | 70 | 56 | 55.2344032096288867 | 63.79089576421327 | 19940
D | 55 | 41 | 40.9211278586278586 | 50.27198833448675 | 7696
E | 40 | 30 | 23.8611898016997167 | 36.816299847461934 | 1059
(5 rows)
Time: 210.886 ms
可以看到,按照百分比位置和分数比例进行计算的赋分,略有差异。而且分值比例算法的效率更高,仅需要300ms左右。
普通程序实现
前面讨论的是在Postgres中,使用窗口函数的相关计算。其实我们如果认真分析这一实现过程,就会发现,只需要提供一个一分一段表,其实是不需要原始数据,也可以进行赋分的计算。这样,就可以将赋分计算做出一个外部程序并接口化。这个计算的输入数据是一个一分一段表,而输出结果是一分一段表的扩展,增加了相关赋分的记录。
首先是基于原始数据,生成一个一分一段表:
sql
with R as (select score, count(1) sct from cdata where course = 1 group by 1 )
select json_build_object('score', score, 'sct', sum(sct) over (order by score desc))
from R order by score desc;
json_build_object
-------------------------------
{"score" : 100, "sct" : 12}
{"score" : 99, "sct" : 110}
{"score" : 98, "sct" : 216}
{"score" : 97, "sct" : 363}
{"score" : 96, "sct" : 545}
{"score" : 95, "sct" : 738}
{"score" : 94, "sct" : 979}
{"score" : 93, "sct" : 1143}
{"score" : 92, "sct" : 1323}
...
然后使用将查询结果作为参数,传入一个外部程序来进行赋分计算的处理,我们以js程序为例,可以进行分值比例的转换操作,实例代码如下:
js
// 执行和结果如下
node t.js
[
{ score: 100, sct: 12, fscore: 100 },
{ score: 99, sct: 110, fscore: 99 },
{ score: 98, sct: 216, fscore: 99 },
{ score: 97, sct: 363, fscore: 98 },
{ score: 96, sct: 545, fscore: 98 },
...
{ score: 20, sct: 58565, fscore: 34 },
{ score: 19, sct: 58620, fscore: 33 },
{ score: 18, sct: 58658, fscore: 32 },
{ score: 17, sct: 58682, fscore: 31 },
{ score: 16, sct: 58696, fscore: 31 },
{ score: 15, sct: 58700, fscore: 30 }
]
统计分析
前面的操作,只是简单的进行了赋分的计算。而要分析这个赋分计算的效果,还需要一些后续处理和分析。
简单的处理就是最基本的聚合计算,如最高分、最低分、平均分、四分位(25%、50%、75%)分、标准差等,都是很常规的计算,可以在数据库中直接处理。
下面是笔者的测试数据和例子:
sql
select slevel, count(1) sct,
round(avg(score),2),
round(avg(fscore)::numeric,2) favg,
round(VAR_POP(fscore)::numeric,2) varp ,
round(STDDEV_POP(fscore)::numeric,2) stdvarp,
round(PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY fscore)::numeric ,2) m25,
round(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY fscore)::numeric ,2) m5,
round(PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY fscore)::numeric ,2) m75
from cdata where course =1 group by 1;
slevel | sct | round | favg | varp | stdvarp | m25 | m5 | m75
--------+--------+-------+-------+-------+---------+-------+-------+-------
A | 54994 | 80.96 | 92.61 | 18.53 | 4.30 | 89.29 | 93.15 | 96.47
B | 130348 | 67.44 | 77.91 | 18.89 | 4.35 | 73.75 | 77.70 | 81.20
C | 124980 | 55.64 | 63.19 | 18.90 | 4.35 | 59.82 | 63.28 | 67.23
D | 42152 | 43.72 | 48.46 | 19.33 | 4.40 | 44.38 | 47.93 | 52.97
E | 6226 | 30.68 | 35.48 | 9.77 | 3.12 | 32.54 | 35.66 | 38.27
(5 rows)
Time: 906.042 ms
复杂一点的处理分析,包括"四维二度"相关分析,笔者正在研究实现。这里的关键是这些统计指标的清晰的数学定义,但笔者觉得基本上也脱离不了常规的统计和概率分析理论,普通的数据库级别的操作,通过过程和组合也应该可以完成。
小结
本文探讨了赋分制和一种计算规则,并基于Postgres数据库实现了这个赋分转换操作的基本流程和实现相关代码。可以看到,在规则明确清晰的情况之下,使用数据库本身的统计分析和数据处理能力,可以非常高效的进行赋分数据的转换操作和相关统计分析。