Julia语言程序设计
上QQ阅读APP看书,第一时间看更新

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排列。

有别于第一种实现方式,本方式采用复合类型对数据和操作进行了封装,在业务概念或逻辑上能够显得更为条理清晰,而且整个实现过程也并不复杂。