1.5.2 体型分布案例
这次给个复杂点的例子:有1000个人的体型样本,包括体重与身高两项指标,不考虑性别和年龄因素,计算每个人的BMI(Body Mass Index)指数,并根据关于肥胖的中国参考标准(见表1-2),统计各种体型分类的人数。为了编程的方便,在表中先对BMI分类进行了编号,对应1~6类。另外,在实现时,初始的样本数据可采用随机数的方式生成。
表1-2 体型BMI指数中国标准(数据来自网络)
下面,我们采用两种不同的方式实现上述的需求:一种是基于数组的方式,另外一种基于复合结构的方式,具体见下文。由于涉及的代码会比较多,所以本例不再在REPL中演示,而在脚本中实现,详细源码可在https://gitee.com/juliaprog/bookexamples.git中下载。
1.数组实现方式
首先在磁盘中新建一个名称为bmi_array.jl的文件,并使用常用的文本编辑器(例如VS Code、Notepad++、Sublime、UltraEditor、Atom等)打开进行编辑。
第一步,使用随机函数rand()生成原始数据,包括1000个人的身高及体重样本。实现的代码如下:
# 使用均匀分布随机数生成1000个身高样本,取值范围为 [0,1) heights = rand(Float64, 1000)
以及
# 使用均匀分布随机数生成1000个体重样本,取值范围为 [0,1) weights = rand(Float64, 1000)
其中的heights与weights均是元素类型为Float64的数组,长度为1000,内容类似于:
1000-element Array{Float64,1}: 0.327989 0.371755 0.640065 0.891165 0.735425 0.428819 ... # 已省略
因为随机函数rand()的取值区间为[0,1),所以需要转换到正常人的身高与体重范围。我们采用区间映射的方式,函数为:
该函数可以将[amin,amax]的任意值a映射到区间[bmin,bmax]中的b值。利用此函数,可以分别将身高heights数组元素值均映射到[1.5,1.8)区间,将体重weights数组元素值映射到[30,100)区间,即身高分布在1.5~1.8米范围,同时体重分布在30~100千克,具体实现语句为:
# 将 身高 数据映射到 [1.5, 1.8) 米 heights = heights .* (1.8-1.5) .+ 1.5 # 将 体重 数据映射到 [30, 100) 千克 weights = weights .* (100-30) .+ 30
由于涉及标量与矢量(数组)的混合计算,所以采用了Julia特有的点操作(后面会进行深入地学习)。上述语句虽然涉及数组的逐元计算,但并没有使用循环结构,语法极为简洁直观。
提示 这两项数据呈现正态分布是较为符合实现实情况的,不过为了转换的方便,我们采用了均匀分布的rand()函数,有兴趣的读者可以更换为randn()函数,来生成初始的样本数据。
接下来,定义一个用于计算BMI指数的函数,如下所示:
bmi(w, h) = w / (h^2)
这是一种Julia式的函数定义方式,适用于实现简短的函数。其中的bmi是函数名,(w,h)中的w和h是输入参数,分别是体重与身高值。
此后,便可基于已有的身高与体重数据计算上述1000个样本的BMI指数了,实现如下:
indexes = broadcast(bmi, weights, heights)
按常规,此处应该有循环,但是并没有。我们利用Julia特有的broadcast()函数,实现了将函数bmi()逐一施用于数组weights和heights元素,并能够自动取得两个数组的对应元素,自动将两对元素作为bmi()函数的输入参数,计算对应体型样本的BMI指数。如果一定想用一下循环结构,可以如下实现:
indexes = Array{Float64,1}(1000) # 创建有1000个元素的一维数组对象 for i in 1:1000 # 循环1000次 indexes[i] = bmi(weights[i], heights[i]) # 成对取得体重与身高数据,计算第i个样本的BMI指数 end
可见,这样的方式中代码量多出了很多,远没有上述的一句话实现简洁。
更令人惊奇的是,对于BMI指数这种计算简单的过程,完全可以不用预先定义bmi()函数,而是采用Julia特有的点操作符直接实现:
indexes = weights ./ (heights.^2)
这同样能够将运算过程自动施用于weights和heights的元素中,而且仍不需要循环结构,表达极为高效、简洁。在本书之后的内容中,我们便能够深入了解这种逐元计算机制,这便是所谓的矢量化计算方法。
在BMI指数计算完成后,我们定义一个名为bmi_category的函数,用于对得到的指数进行分类,代码如下:
# 对BMI指数进行分类 # 1-体重过低,2-正常范围,3-肥胖前期,4-I度肥胖,5-II度肥胖,6-III度肥胖 function bmi_category(index::Float64) class = 0 if index < 18.5 class = 1 elseif index < 24 class = 2 elseif index < 28 class = 3 elseif index < 30 class = 4 elseif index < 40 class = 5 else class = 6 end class # 返回分类编号 end
由于该函数语句较多,所以该函数并没有采用bmi()函数定义时那种“直接赋值”的方式,而是采用了带有function关键字的常规函数定义方式,并利用if~elseif判断结构实现了对输入BMI指数值index进行逐层判断。
在得到最终的分类编号class之后,需要将其值返回。在Julia中,只需在函数结束的最后语句中直接列出该变量即可,显式的return关键字不是必需的。
然后,便可通过该函数对indexes中的1000个BMI指数进行分类了,实现语句为:
classes = bmi_category.(indexes) #注意函数名之后有一个小点号
同样采用点操作实现了数组的逐元计算。这种点操作不仅适用于上述的运算符,也同样适用于普通定义的函数。该语句执行后,会得到类似如下的结果:
1000-element Array{Int64,1}: 2 5 3 1 2 1 5 ... # 其他已省略
最后,对classes中的类别编号进行统计:
# 统计每个类别的数量 for c in [1 2 3 4 5 6] # 遍历6个类别,c为类别ID n = count(x->(x==c), classes) # x->(x==c)为匿名函数 println("category ", c, " ", n) # 打印结果 end
实现中使用了for循环结构对类别编号集合进行遍历,逐一对各类型进行统计。其中的count()函数是Julia内置的,能够对数组中满足条件的元素进行计数,而条件由该函数的第一个参数提供。条件参数需是一个函数对象,且有一个输入参数,并需返回布尔型值。有1处在上述代码中,这个条件函数为x->(x==c),是一个匿名函数,等效于:
condition(x) = (x==c)
不过,因为简短,又需作为另外一个函数的参数,所以采用匿名函数的定义方式是非常合适的。
至此,需求需要实现的功能全部完成了。之后我们打开REPL,执行以下语句:
julia> include("/path/to/bmi_array.jl") # 文件路径根据实际情况提供
便可获得最终的结果,显示的内容类似于:
category 1 291 # 体重过低共计291人 category 2 221 category 3 151 category 4 77 category 5 238 category 6 22 # Ⅲ度肥胖共计22人
这里,我们总结一下:在整个实现中,数据流主要以数组结构表达,并在对数组的逐元操作中,利用Julia的点操作及braodcast()函数两种方式进行矢量化计算,避免了大量的循环结构,代码的实现极为简洁、高效、直观。
2.复合类型实现方式
下面,我们再尝试另外一种实现方式。同样,在磁盘中新建一个名称为bmi_struct.jl的脚本文件,并使用文本编辑器进行编辑。
首先,定义一个复合结构类型,包括四个成员字段,分别表示某个人的身高、体重、BMI指数及BMI分类编号。
mutable struct Person height # 身高,单位米 weight # 体重,单位千克 bmi # 计算得到的BMI指数 class # 根据BMI指数计算得到的分类标识 # 1-体重过低,2-正常范围,3-肥胖前期,4-Ⅰ度肥胖,5-Ⅱ度肥胖,6-Ⅲ度肥胖 end
再定义一个集合类型,用于容纳样本数据,如下所示:
people = Set{Person}()
随后使用均匀分布生成1000个体型数据,并放入集合people中:
for i = 1:1000 h = rand() * (1.8-1.5)+1.5 # 生成身高数据,并将其映射到[1.5, 1.8)区间 w = rand() * (100-30)+30 # 生成体重数据,并将其映射到[30, 100)区间 p = Person(h, w, 0, 0) # 基于身高与体重数据创建Person对象p, # BMI指数和分类编号均初始化为无效的0值 push!(people, p) # 将对象放入集合people中 end
然后定义bmi()函数,基于每个Person对象的身高及体重数据,计算其BMI指数并同时进行分类,代码如下:
function bmi(p::Person) p.bmi = p.weight/(p.height^2) # 计算BMI指数 p.class = bmi_category(p.bmi) # 分类,得到类别ID,已在前文实现过 end
函数bmi()中原型中的::Person用于限定输入参数p变量只能是Person类型。
最后,遍历people中的1000个样本,对BMI类别分别进行统计:
# 对1000个样本执行BMI计算,并统计分布 categories = Dict() # 字典结构,记录各类的人数 for p ∈ people # 遍历1000个样本 bmi(p) # 计算BMI指数并分类,会直接修改p中的属性字段 categories[p.class] = get(categories, p.class, 0) + 1 # 对p.class类的计数器累加 end
实现中,对Dict类型的对象categories可以两种访问方式,一种是与数组下标极为类似,用于获得类别对应的计数器,另一种是get()函数,也是用于获得类别对应的计数器内容,区别在于后者能够在categories还不存在键p.class时,也能够返回有效值(默认值0)。
完成后,打印BMI分类统计的结果。方法极为简单,只需直接列出变量名:
categories
至此,功能全部实现了。打开REPL,执行以下语句:
julia> include("/path/to/bmi_struct.jl") # 文件路径根据实际情况提供
便可获得最终结果,内容类似于:
Dict{Any,Any} with 6 entries: 4 => 89 2 => 219 3 => 158 5 => 234 6 => 18 # Ⅲ度肥胖共计18人 1 => 282 # 体重过低共计282人
不过注意Dict中的数据是无序的,所以打印的内容中没有按照类别ID排列。
有别于第一种实现方式,本方式采用复合类型对数据和操作进行了封装,在业务概念或逻辑上能够显得更为条理清晰,而且整个实现过程也并不复杂。