第 1 章 数据的输入与输出

第 1 章 数据的输入与输出

我们身边每时每刻都有事件发生。有时候,我们记录下了时空中某个特定点发生的离散事件。然后,我们可以把数据(data)定义为一系列的记录,这些记录是某人(或某物)花时间以任何可以想象出的格式写下来(或者呈现出来)的。数据科学家面对的数据有文件、数据库、Web 服务,等等。通常,人们在定义能够准确地表示各种变量的名称、类型、变量值的范围以及这些变量之间关系的模式或者数据模型时,会遇到许多困难。然而,在获取数据时,并非总能强行采用某种模式。真实的数据(即使是精心设计的数据库)通常会存在以下问题:缺失值、拼写错误、不正确的格式化类型、对同一值的重复表示,甚至将几个变量拼接成了一个变量。尽管人们对实现机器学习算法以及创建漂亮图形感到兴奋,但是,数据科学中最重要且最耗时的工作是准备数据和确保其完整性。

1.1 究竟何谓数据

你的最终目标是从源头获取数据,通过统计分析或者学习对数据进行归约,然后根据学到的东西给出某种知识——通常采用图形的形式表示。然而,即使所求结果是单个值,例如总收益、参与度最高的用户或者品质因数,也需要遵循相同的流程:输入数据(input data)→归约分析(reductive analysis)→输出数据(output data)。

鉴于实际的数据科学受业务问题驱动,因此从右向左来看这个流程会更方便一些。首先,把试图回答的问题正式确定下来。例如,需要的是按地区排列的顶级用户列表、关于下周每天收入的预测,还是库存物品之间相似性分布的图示?下一步,进行一系列分析,以回答这些问题。最后,既然已经选定解决问题的方法,那么为了实现这个目标,究竟需要哪些数据?你会惊奇地发现自己没有所需要的数据。另外,通常你会发现,一些比预想的简单得多的分析工具就足以得到所需的输出。

本章将探究从各种数据源读取数据以及写入数据的具体细节。重要的是,需要问自己:对于后续每个步骤,需要什么样的数据模型?也许,为了容纳这些数据,建立一系列的数值数组类型(例如 double[][]int[]String[])就足够了。这样做可能是有益处的:创建容器类以保存每条数据记录,然后把这些对象添加到 ListMap 中。还有另外一种有用的数据模型,即在 JavaScript 对象表示法(JSON)文档中,把每条记录都表示为键–值对的集合。具体应该采用哪种数据模型,很大程度上取决于后续数据消费过程的输入需求。

1.2 数据模型

数据采用哪种形式,需要转换成什么形式,才能继续往下进行?假设文件 somefile.txt 包含数行数据,每一行都有 idyear 以及 city 数据。

1.2.1 一维数组

对于这个示例而言,最简单的数据模型是为 idyear 以及 city 这 3 个变量创建一系列的数组:

int[] id = new int[1024];
int[] year = new int[1024];
String[] city = new String[1024];

由于 BufferedReader 循环遍历该文件的每一行,因此借助于增量计数器,可以把变量的值放置到各个数组的相应位置中。对于已知维度的洁净数据,这种数据模型可能就足够了,此时所有代码都放入同一个可执行类中。可以直接把这个数据提供给任何数量的统计分析算法或者学习算法。然而,有时可能需要将代码模块化,并构建适合于数据源与数据模型的每种组合的类以及随后的方法。在这种情况下,为了适应新的参数而必须改变现有方法的签名时,在数组之间切换会变得困难。

1.2.2 多维数组

若想每一行容纳一条记录的所有数据,则这些数据必须是相同的类型。所以,在上面的例子中,只有把城市赋值为整型值,才能工作。

int[] row1 = {1, 2014, 1};
int[] row2 = {2, 2015, 1};
int[] row3 = {3, 2014, 2};

也可以将其处理为二维数组。

int[][] data = {{1, 2014, 1}, {2, 2015, 1}, {3, 2014, 2}};

第一次查看数据集时,可能已经有复杂的数据模型,或者只是文本、整数、double 型以及日期时间型数据的混合体。理想情况下,当确定了要将哪些数据输入到统计分析算法或者学习算法中时,这些数据已经被转换为二维数组,数组的元素是 double 型。然而,需要大量工作才能达到这一步。一方面,以矩阵的形式提供数据,从而采用机器学习方法取得进展是方便的;另一方面,可能不知道需要做哪些折中,或者已经扩散了哪些未被检测到的错误。

1.2.3 数据对象

另一种选择是创建容器类,然后把这些容器对象添加到 ListMap 等集合中。这样做的优点是,将一条特定记录的所有值保持在一起,向类中添加新成员时,不会破坏任何以这个类作为参数的方法。文件 somefile.txt 中的数据可以用下面的类表示:

class Record {
    int id;
    int year;
    String city;
}

确保该类尽可能是轻量级的,因为包含这些对象的集合(ListMap)会形成大的数据集。理想情况下,任何作用在 Record 对象上的方法应该都是其所属类(该类可能命名为 RecordUtils)中的静态方法。

集合结构 List 用来存放所有 Record 对象。

List<Record> listOfRecords = new ArrayList<>();

采用 BufferedReader 遍历数据文件时,会对每一行进行解析,并将其内容存放在新的 Record 实例中,再把每个新的 Record 实例添加到 List<Record> listOfRecords 中。若需要通过键进行快速查找,并获取某个单独的 Record 实例,则可以使用 Map

Map<String, Record> mapOfRecords = new HashMap<>();

对于特定记录而言,其键应当是唯一的标识符,例如记录编号或者 URL。

1.2.4 矩阵和向量

矩阵(matrix)和向量(vector)是更高层次的数据结构,它们分别由二维数组与一维数组构成。通常,数据集包含多个行与列,可以说这些变量形成了二维数组(或矩阵\boldsymbol{X} ,其中有 mn 列。若选择 i 作为行索引、j 作为列索引,则 m\times n 矩阵的每个元素是 x_{i,j}

\begin{pmatrix}x_{1,1}&x_{1,2}&\cdots&x_{1,n}\\x_{2,1}&x_{2,2}&\cdots&x_{2,n}\\\vdots&\vdots&\ddots&\vdots\\x_{m,1}&x_{m,2}&\cdots&x_{m,n}\end{pmatrix}

把值放入类似于矩阵的数据结构中,可以获得便利。在多种情况下,要对数据进行数学运算。矩阵实例可以拥有完成这些运算的抽象方法,也有适合于手头某项任务的实现细节。第 2 章会具体讨论矩阵和向量。

1.2.5 JSON

JavaScript 对象表示法(JavaScript Object Notation,JSON)已经成为表示数据的一种流行格式。通常,JSON 数据采用 json.org 列举的简单规则来表示:采用双引号。结尾处没有逗号。JSON 对象的外层采用了花括号,内部是任意有效的键–值对集合,这些键–值对用逗号分隔(由于不能保证其内容的顺序,因此将其当作 HashMap 类型来处理)。

{"city":"San Francisco", "year": 2020, "id": 2, "event_codes":[20, 22, 34, 19]}

JSON 数组的外层是方括号,其内部的有效 JSON 内容采用逗号分隔(数组内容的顺序是有保证的,因此当作 ArrayList 类型来处理)。

[40, 50, 70, "text", {"city":"San Francisco"}]

有两类 JSON 数据结构。一些数据文件包含完整的 JSON 对象或数组,这些通常是配置文件。然而,另一类常见数据结构是由独立 JSON 对象构成的文本文件,每个 JSON 对象占据一行。注意,严格来说,这种类型的数据结构(由 JSON 对象构成的列表)并不是 JSON 对象或者数组,原因是文件中并没有闭合的大括号,或者是由于行之间没有逗号。正因如此,试图把整个数据结构解析为 JSON 对象或数组会失败。

1.3 处理实际数据

实际数据往往是杂乱、不完整、不正确的,有时甚至是不连贯的。若正在处理的是“完美”的数据集,那是因为有人已经花费大量时间与精力使它变成那个样子。也可能是,数据事实上并不完美,而你正在不知不觉地对垃圾数据进行分析。唯一可靠的途径是从源头采集数据,然后自己处理。这样,若发生了错误,也可以知道应该由谁负责。

1.3.1 空值

空值以多种形式出现。若数据在 Java 内部传递,则完全可能出现空值。如果正从文本文件解析字符串,那么空值可能会表示为多种形式的字符串,包括 "null""NULL""na" 等其他字符串,甚至是句点。在任何一种情况下(空值或者 null 字符串),都需要对其进行记录。

private boolean checkNull(String value) {
    return value == null || "null".equalsIgnoreCase(value);
}

通常,空值已经被记录为一个空格或者一系列空格。尽管这样做有时让人烦恼,但是它可以达到某种目的,因为把数据点不存在这一概念编码为 0 并不总是合适的。例如,如果在记录二值变量(0与 1)时遇到了一个并不知道其值的项,那么为其错误地赋值为 0(并写到了文件中),就会错误地把真的负例赋给这个项。在向文本文件写入空值时,我的偏好是写入长度为 0 的字符串。

1.3.2 空格

空格广泛存在于实际数据中。采用 String.isEmpty() 方法可以直接检查字符串是否为空字符串。然而请注意,由空格组成的字符串(即使只有单个空格)并不是空的!首先,用 String.trim() 方法移除那些位于输入值开头部分或结尾部分的空格,然后检查它的长度。只有在字符串长度为 0 时,String.isEmpty() 才返回 true

private boolean checkBlank(String value) {
    return value.trim().isEmpty();
}

1.3.3 解析错误

一旦知道了字符串的值既不是空值也不是空格,就将其解析为所需要的类型。这里不讨论如何将字符串解析为字符串,因为并没有什么需要解析的。

当处理数值类型时,把字符串直接转换为基本类型,例如 doubleintlong,是不明智的。推荐做法是采用对象封装类,例如 DoubleIntegerLong,它们都有字符串解析方法。如果发生错误,那么这些方法会抛出 NumberFormatException 异常。可以捕获该异常,并更新解析错误计数器;也可以打印该错误,或将其记入日志中。

try {
    double d = Double.parseDouble(value);
    // 处理d
} catch (NumberFormatException e) {
    // 对解析错误计数器进行递增等操作
}

同样,也可以用 OffsetDateTime.parse() 方法解析日期时间格式的字符串。如果在输入字符串中发生错误,那么可以捕获到 DateTimeParseException 异常,并记入日志中。

try {
    OffsetDateTime odt = OffsetDateTime.parse(value);
    // 处理odt
} catch (DateTimeParseException e) {
    // 对解析错误计数器进行递增等操作
}

1.3.4 异常值

清理并解析数据之后,即可检查其值是否符合需求。如果期望值是 0 或 1,得到的却是 2,那么显然这个值超出了范围,可以把这个数据点标记为异常值。就如同在处理空值与空格时所做的那样,可以对这个值进行布尔测试,以决定它是否处于可接受的值范围之内。这种做法对于数值类型、字符串以及日期时间类型都是适合的。

对数值类型进行范围检查时,需要知道可接受的最大值与最小值,以及这两个值是否包括在内。例如,若设定 minValue = 1.0minValueInclusive = true,则所有大于或等于 1.0 的值都将通过测试。如果设定 minValueInclusive = false,则只有大于 1.0 的那些值才会通过测试。代码如下:

public boolean checkRange(double value) {
    boolean minBit = (minValueInclusive) ? value >= minValue : value > minValue;
    boolean maxBit = (maxValueInclusive) ? value <= maxValue : value < maxValue;
    return minBit && maxBit;
}

可以为整型数据写出类似的方法。

也可以通过设置有效字符串的枚举,来检查字符串的值是否处于可接受范围之内。为此,可以通过创建有效字符串的 Set 实例(例如 validItems)来实现,其中的 Set.contains() 方法用于测试输入值的有效性。

private boolean checkRange(String value) {
    return validItems.contains(value);
}

对于 DateTime 对象,可以检查日期是否在最小日期之后,在最大日期之前。在这种情况下,把日期的最小值与最大值定义为 OffsetDateTime 对象,然后测试输入的日期时间是否介于最小值与最大值之间。注意,OffsetDateTime.isBefore()OffsetDateTime.isAfter() 是不包括边界值的。如果输入的日期时间等于最小值或最大值,那么测试将返回 false。代码如下:

private boolean checkRange(OffsetDateTime odt) {
    return odt.isAfter(minDate) && odt.isBefore(maxDate);
}

1.4 管理数据文件

数据科学艺术始于对数据文件的管理。选择如何构建数据集不仅关系到效率,而且还关系到灵活性。读写文件有多种选择,最基本的方法是采用 FileReader 实例把整个文件的内容读入到 String 类型中,然后把这个 String 解析为相应的数据模型。对于大文件而言,采用 BufferedReader 分别读文件的每一行,可以避免输入输出错误。这里的策略是在读每一行时进行解析,仅保留那些需要的值,并把这些记录填充到数据结构中。如果每行有 1000 个变量,而只需要其中的 3 个,则没必要保存所有变量。同样,若某一行中的数据不符合某个标准,则也没有必要保存该行。对于大型数据集,相比于把所有行读入到字符串数组(String[])中,随后再进行解析,这种方法可以节省很多资源。在管理数据文件的这一步骤中,考虑得越多,收获就越大。不论是统计、学习还是绘制,这些后续的每个步骤都将依赖于构建数据集时的决策。俗话说“输入的是无用数据,那么输出的也一定是无用数据”,这绝对在理。

1.4.1 首先理解文件内容

数据文件的结构繁杂,可能会呈现一些不符合需要的特征(feature)。回忆一下,ASCII 文件仅是打印到每一行上的一些 ASCII 字符的集合。它无法保证数值的格式或精度,也不能保证是采用单引号还是双引号,以及是否包含大量空格、空值以及换行符。简而言之,不管对文件内容做了怎样的假设,文件每一行所包含的内容都可能是各式各样的。用 Java 读取文件之前,先用文本编辑器或命令行看一下该文件。注意每一行中每一项的个数、位置以及类型,要特别关注缺失的值或空值是如何表示的。同样,要注意分隔符的类型,以及任何描述数据的头部。若文件足够小,则可以通过肉眼来查看哪些行有缺失或格式错误。例如,假定在 bash shell 中采用 UNIX 命令 less 来查看 somefile.txt 文件:

bash$ less somefile.txt

"id","year","city"
1,2015,"San Francisco"
2,2014,"New York"
3,2012,"Los Angeles"
...

那么可以看到数据集的格式采用的是用逗号分隔的值(CSV),它由 idyear 以及 city 这 3 列组成,可以快速地检查文件的总行数。

bash$ wc -l somefile.txt
1025

这表明文件共有 1024 行数据,再加上一行头部。其他格式也是可能的,例如以制表符分隔的值(TSV)、所有值都拼接在一起的“大字符串”格式,以及 JSON。对于大文件,也许想看前 100 行左右,并把它们转换为一个摘要文件,以便于应用的开发。

bash$ head -100 filename > new_filename

在某些情况下,文件太大了,以至于无法用一双眼睛来细看以发现其结构或错误。显然,在检查具有 1000 列的数据文件时,会遇到障碍。同样,也不太可能通过滚动有 100 万行数据的文件来发现其中的格式错误。在这种情况下,必须有现存的数据字典,用以描述列的格式,以及每一列预期的数据类型(例如整型、浮点型、文本)。采用 Java 对文件进行解析时,可以用编程方式检查每一行数据。这期间,程序可能会抛出异常,而且也许会将不合规则的那些行的内容全部打印出来,以便于检查是什么出错了。

1.4.2 读取文本文件

读取文本文件的通常做法是创建 FileReader 实例,其外层是 BufferedReader,这样可以读取每一行。在这里,FileReader 的参数是 String 类型的文件名,但是 FileReader 也可以采用 File 对象作为它的参数。当文件名和路径依赖于操作系统时,File 对象是有用的。下面是采用 BufferedReader 从文件中按行读取的一般形式:

try(BufferedReader br = new BufferedReader(new FileReader("somefile.txt")) ) {
    String columnNames = br.readline(); // 若文件存在,则仅执行这一操作
    String line;
    while ((line = br.readLine()) != null) {
        /* 解析每一行 */
        // TODO
    }
} catch (Exception e) {
    System.err.println(e.getMessage()); // 或记录错误
}

若文件在远程的某地,则也可以做同样的事情。

URL url = new URL("http://storage.example.com/public-data/somefile.txt");
try(BufferedReader br = new BufferedReader(
    new InputStreamReader(url.openStream())) ) {
    String columnNames = br.readline(); // 若文件存在,则仅执行这一操作
    String line;
    while ((line = br.readLine()) != null) {
        // TODO,解析每一行
    }
} catch (Exception e) {
    System.err.println(e.getMessage()); // 或记录错误
}

此处只需要关注如何解析每一行。

  1. 解析大字符串

    考虑某个文件,其中每一行是一个“大字符串”,它由一些值以及任意子字符串拼接而成,这些具有起始位置与结束位置的子字符串对特定变量进行编码。

    0001201503
    0002201401
    0003201202

    前四位数字是编号(id)值,接下来的四位是年份(year),最后两位是城市(city)编码。注意,每一行都可能有几千个字符那么长,字符子串的位置至关重要。典型的情况是,编号用 0 填充,空值用空白表示。注意浮点数中的句点(例如 32.456)被记作空格,和其他“奇怪的”字符一样!通常,文本字符串采用数值编码。例如,在上面例子中,NewYork = 01Los Angeles = 02 以及 San Francisco = 03

    此时,可以用 String.substring(int beginIndex, int endIndex) 方法访问每一行的值。注意,子字符串从 beginIndex 位置开始,到(但不包括)endIndex 位置结束。

    /* 解析每一行 */
    int id = Integer.parseInt(line.substring(0, 4));
    int year = Integer.parseInt(line.substring(4, 8));
    int city = Integer.parseInt(line.substring(8, 10));
  2. 解析用分界符界定的字符串

    考虑到电子表格与数据库转储的广泛应用,你很有可能会在某一时刻遇到 CSV 数据集。解析这种文件可能并不那么容易。假设示例中的数据采用 CSV 文件格式:

    1,2015,"San Francisco"
    2,2014,"New York"
    3,2012,"Los Angeles"

    然后要做的是,用 String.split(",") 方法解析,并用 String.trim() 方法去掉任何位于开始与结尾处的令人烦恼的空格。同时,有必要采用 String.replace("\"", "") 方法去除字符串两边的引号。

    /* 解析每一行 */
    String[] s = line.split(",");
    int id = Integer.parseInt(s[0].trim());
    int year = Integer.parseInt(s[1].trim());
    String city = s[2].trim().replace("\"", "");

    下面的例子中,文件 somefile.txt 中的数据已经用制表符分隔。

    1        2015        "San Francisco"
    2        2014        "New York"
    3        2012        "Los Angeles"

    通过把前面代码中的 String[] s = line.split(",") 替换为 String[] s = line.split("\t"),可以对用制表符界定的数据进行分离。

    在某一时刻,你一定会遇到有些字段中包含逗号的 CSV 文件。这样的例子包括从用户博客中获取的文本。另一个例子是在一列中出现了不规范的数据,例如“San Francisco, CA”,它没有把城市与州分别放入两个列中。这在解析时非常棘手,并需要正则表达式。但是,为什么不用 Apache Commons CSV 解析库呢?

    /* 解析每一行 */
    CSVParser parser = CSVParser.parse(line, CSVFormat.RFC4180);
    for(CSVRecord cr : parser) {
        int id = cr.get(1); // 列从1开始,而不是从0开始
        int year = cr.get(2);
        String city = cr.get(3);
    }

    Apache Commons CSV 库也可以处理包括 CSVFormat.EXCEL、CSVFormat.MYSQL 以及 CSVFormat.TDF 在内的常见格式。

  3. 解析 JSON 字符串

    JSON 是一种用于对 JavaScript 对象进行序列化的协议,可以扩展到所有类型的数据。这种紧凑且易读的格式普遍存在于因特网数据 API 中(特别是 RESTful 服务),并且是许多 NoSQL 解决方案(例如 MongoDB 与 CouchDB)的标准格式。自 9.3 版本开始,PostgreSQL 数据库提供了 JSON 数据类型,可以查询原生 JSON 字段。其显著优势是便于人类阅读,数据结构清晰可见,并且可以“优质打印”。就 Java 而言,JSON 不过是 HashMapsArrayLists 的集合而已,可以采用能够想象到的任何嵌套配置。对于前面示例的每一行数据,可以把那些值放入键–值对中,从而成为 JSON 字符串格式;字符串采用双引号(而不是单引号)括起来,并且不允许尾端出现逗号。

    {"id":1, "year":2015, "city":"San Francisco"}
    {"id":2, "year":2014, "city":"New York"}
    {"id":3, "year":2012, "city":"Los Angeles"}

    注意,从技术上讲,整个文件本身不是 JSON 对象,把整个文件本身解析为 JSON 对象会失败。要成为有效的 JSON 格式,每一行必须用逗号分隔,然后整个组采用方括号括起来,这将形成 JSON 数组。然而,写出这样的结构是低效且无用的。更方便且更有用的做法是用字符串表示的 JSON 对象构成按行组织的栈。注意,JSON 解析器并不知道键–值对中值的类型。因此,要获得它的 String 表示,然后采用装箱方法将其解析为基本类型。现在,采用 org.simple.json 可以直接构造数据集。

    /* 在while循环外创建JSON解析器 */
    JSONParser parser = new JSONParser();
    ...
    
    /* 对解析得到的字符串进行类型转换,以创建对象 */
    JSONObject obj = (JSONObject) parser.parse(line);
    int id = Integer.parseInt(j.get("id").toString());
    int year = Integer.parseInt(j.get("year").toString());
    String city = j.get("city").toString();

1.4.3 读取JSON文件

本节讨论包含字符串化的 JSON 对象或数组的文件。需要提前知道文件是 JSON 对象还是数组。例如,如果在命令行中采用 ls 命令查看文件,将看到该文件中是包含花括号(对象)还是方括号(数组)。

{{"id":1, "year":2015, "city":"San Francisco"},
 {"id":2, "year":2014, "city":"New York"},
 {"id":3, "year":2012, "city":"Los Angeles"}}

然后采用 Simple JSON 库。

JSONParser parser = new JSONParser();
try{
    JSONObject jObj = (JSONObject) parser.parse(new FileReader("data.json"));
    // TODO,对jObj对象进行处理
} catch (IOException|ParseException e) {
    System.err.println(e.getMessage());
}

若它是数组:

[{"id":1, "year":2015, "city":"San Francisco"},
 {"id":2, "year":2014, "city":"New York"},
 {"id":3, "year":2012, "city":"Los Angeles"}]

则可以解析整个 JSON 数组。

JSONParser parser = new JSONParser();
try{
    JSONArray jArr = (JSONArray) parser.parse(new FileReader("data.json"));
    // TODO,对jObj对象进行处理
} catch (IOException|ParseException e) {
    System.err.println(e.getMessage());
}

 如果某个文件中确实每一行都是 JSON 对象,那么严格来说,该文件并不是合格的 JSON 数据结构。请参考 1.4.2 节,在那里读取文本文件时,是按照一次解析一行的方式来解析 JSON 对象的。

1.4.4 读取图像文件

使用图像作为学习算法的输入时,需要把图像格式(例如 PNG)转换为合适的数据结构,例如矩阵或向量。这里有几点需要注意。首先,图像是一个二维数组,它包括坐标 {x_1,x_2\},以及对应的颜色或强度值集合 {y_1\cdots\},该集合的元素可以用单个整型值存储。若所需的只是以二维整型数组存储的原始数值(这里标记为 data),则可以用下面的代码读入这个缓冲的图像:

BufferedImage img = null;
try {
    img = ImageIO.read(new File("Image.png"));
    int height = img.getHeight();
    int width = img.getWidth();
    int[][] data = new int[height][width];
    for (int i = 0; i < height; i++) {
        for (int j = 0; j < width; j++) {
            int rgb = img.getRGB(i, j); // 负整数
            data[i][j] = rgb;
        }
    }
} catch (IOException e) {
    // 处理异常
}

通过对整数进行移位操作,可以将其转换为 RGB 分量(红、绿、蓝):

int blue = 0x0000ff & rgb;
int green = 0x0000ff & (rgb >> 8);
int red = 0x0000ff & (rgb >> 16);
int alpha = 0x0000ff & (rgb >> 24);

然而,可以用下面的方法从光栅中方便地获取该信息:

byte[] pixels = ((DataBufferByte) img.getRaster().getDataBuffer()).getData();
for (int i = 0; i < pixels.length / 3 ; i++) {
    int blue = Byte.toUnsignedInt(pixels[3*i]);
    int green = Byte.toUnsignedInt(pixels[3*i+1]);
    int red = Byte.toUnsignedInt(pixels[3*i+2]);
}

颜色可能并不重要,灰度级或许正是所需要的:

// 把RGB转换为灰度级(0~1),其中颜色值的范围是0~255
double gray = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255.0

此外,在某些场合,二维表示并不是必需的。通过把矩阵的每一行拼接到新向量上,可以把矩阵转换为向量,例如,\boldsymbol{x}_n=\boldsymbol{x}_1,\boldsymbol{x}_2,\cdots,其中向量维数 n 等于矩阵的行数乘以列数,即 m\times p。在著名的手写图像数据集 MNIST 中,数据已经进行了纠正(居中与裁剪),并转换成二进制格式。因此需要一种专门格式读取该数据(见附录 A),但是它已经是向量(一维)而不是矩阵(二维)格式了。针对 MNIST 数据集的学习技术通常涉及这种向量化的格式。

1.4.5 写入文本文件

把数据写入到文件中的一般方式是采用 FileWriter 类,但是依旧要推荐采用 BufferedWriter 类,以防止任何输入 / 输出错误。通常做法是把想要写入文件的数据转换为单一的字符串。对于示例中的 3 个变量,可以采用所选择的分界符(要么是逗号,要么是 \t)来手动操作。

/* 对于每个 Record 记录实例 */
String output = Integer.toString(record.id) + "," +
Integer.toString(record.year) + "," + record.city;

在 Java 8 中,String.join(delimiter, elements) 方法是方便的。

/* 在Java 8中 */
String newString = String.join(",", {"a", "b", "c"});

/* 或者采用Iterator进行迭代 */
String newString = String.join(",", myList);

此外,还可以使用 Apache Commons Lang 中的 StringUtils.join(elements, delimiter) 方法,或者在循环中使用原生类 StringBuilder

/* 在 Java 7 中 */
String[] strings = {"a", "b", "c"};

/* 创建StringBuilder对象并添加第一个成员 */
StringBuilder sb;
sb.append(strings[0]);

/* 略过第一个字符串,因为已经拥有它了 */
for(int i = 1; i < strings.length, i++){
    /* 此处选择分隔符……对于制表符,也可以是\t */
    sb.append(",");
    sb.append(strings[i]);
}

String newString = sb.toString();

注意,连续地进行字符串串联操作(myString += myString_part)会调用 StringBuilder 类,因此也可以直接使用 StringBuilder 类(或者不用)。在任何情况下,字符串都是逐行写入的。注意,BufferedWriter.write(String) 方法不会写新行。如果想把数据的每条记录都写到单独的一行,则必须调用 BufferedWriter.newLine() 方法。

try(BufferedWriter bw = new BufferedWriter(new FileWriter("somefile.txt")) ) {
    for(String s : myStringList){
        bw.write(s);
        /* 不要忘记追加新行! */
        bw.newLine();
    }
} catch (Exception e) {
    System.out.println(e.getMessage());
}

前面的代码覆盖了文件名指定的文件中所有现存的数据。在某些场合,需要向现存文件追加数据。FileWriter 类有可选的布尔型字段 append,若不使用这个字段的话,则其默认值是 false。为了打开文件以追加到下一可用的行,可以采用下面的代码:

/* 设置FileWriter的append位,保留现有数据,并追加新数据 */
try(BufferedWriter bw = new BufferedWriter(
    new FileWriter("somefile.txt", true))) {
    for(String s : myStringList){
        bw.write(s);
        /* 不要忘记追加新行! */
        bw.newLine();
    }
} catch (Exception e) {
    System.out.println(e.getMessage());
}

另一种选择是使用 PrintWriter 类,它将 BufferedWriter.FileWriter 对象作为参数。PrintWriter 类有个 println() 方法,无论在何种操作系统上,它都采用原生换行符。因此在代码中可以不用 \n。这样做的好处是,不用考虑添加那些麻烦的换行符。如果在你自己的计算机(以及相应的操作系统)上生成文本文件,并且是你自己使用这些文件,那么上述方法同样适用。下面是应用 PrintWriter 类的例子:

try(PrintWriter pw = new PrintWriter(new BufferedWriter(
    new FileWriter("somefile.txt"))) ) {
    for(String s : myStringList){
        /* 添加新行! */
        pw.println(s);
    }
} catch (Exception e) {
    System.out.println(e.getMessage());
}

对于 JSON 数据,这些方法都适用。采用 JSONObject.toString() 方法可以把每个 JSON 对象转换为 String,然后把这个 String 写入文件中。如果你正在写 JSON 对象,例如配置文件,就像下面一样简单:

JSONObject obj = ...

try(BufferedWriter bw = new BufferedWriter(new FileWriter("somefile.txt")) ) {
    bw.write(obj.toString());
}
} catch (Exception e) {
    System.out.println(e.getMessage());
}

当创建 JSON 数据文件(关于 JSON 对象的栈)时,遍历 JSONObjects 集合:

List<JSONObject> dataList = ...

try(BufferedWriter bw = new BufferedWriter(new FileWriter("somefile.txt")) ) {
    for(JSONObject obj : dataList){
        bw.write(obj.toString());
        /* 不要忘记追加新行! */
        bw.newLine();
    }
} catch (Exception e) {
    System.out.println(e.getMessage());
}

如果文件是要累积的,那么不要忘记设置 FileWriter 中的追加位。只需通过在 FileWriter 中设置追加位,就可以把更多 JSON 记录添加到文件末尾:

try(BufferedWriter bw = new BufferedWriter(
    new FileWriter("somefile.txt", true)) ) {
...
}

1.5 掌握数据库操作

关系数据库(例如 MySQL)的稳健性与灵活性使得它们成为许多应用场合中最适合的技术。作为一名数据科学家,你很可能会与关系数据库交互,以连接到更大的应用,或者会为数据科学小组的任务生成一些有组织的压缩数据表格。无论哪种情况,掌握命令行、结构化查询语言(SQL)以及 Java 数据库连接(JDBC)都是关键的技能。

1.5.1 命令行客户端

对于管理数据库以及执行查询而言,命令行是很棒的环境。作为交互式 shell,客户端可以对探索数据的命令进行快速修改。查询语句在命令行中通过后,你可以在以后把 SQL 写入到 Java 程序中。为了满足更加灵活的运用,查询中可以包括参数。所有流行的数据库,例如 MySQL、PostgreSQL 和 SQLite,都有命令行客户端。在已经安装了 MySQL(用于开发)的系统中(例如个人计算机),应该能够匿名登录以进行数据库连接,其中数据库名是可选的。

bash$ mysql <database>

然而,你也许不能创建新数据库。可以用如下命令以数据库管理员身份登录:

bash$ mysql -u root <database>

这样就具有了完全的访问权限和特权。在其他任何情况下(例如,正连接到生产机器、远程实例,或者基于云的实例),需要下面的方法:

bash$ mysql -h host -P port -u user -p password <database>

一旦连接上,就可以使用 MySQL shell 进行查询,列出有访问权限的所有数据库、已经连接的数据库名以及用户名:

mysql> SHOW DATABASES;

若要切换到新数据库,可以采用 USE dbname 命令:

mysql> USE myDB;

现在可以创建表了:

mysql> CREATE TABLE my_table(id INT PRIMARY KEY, stuff VARCHAR(256));

更进一步,如果把那些创建表的脚本另存为文件,下面的命令将读取并执行该文件:

mysql> SOURCE <filename>;

当然,也许你想知道数据库中有哪些表。可以用下面的命令:

mysql> SHOW TABLES;

若想得到表的详细描述,包括列名、数据类型和约束,则可以用下面的命令:

mysql> DESCRIBE <tablename>;

1.5.2 结构化查询语言

对于浏览数据而言,结构化查询语言(SQL)是个强大的工具。尽管在企业软件应用中,对象关系映射(ORM)框架有一席之地,但是对于数据科学家所面对的任务类型而言,ORM 框架太受限制了。提高 SQL 技能,并先掌握下面的基础知识是个不错的主意。

  1. 创建

    为了创建数据库和表,采用下面的 SQL 语句:

    CREATE DATABASE <databasename>;
    
    CREATE TABLE <tablename> (col1 type, col2 type, ...);
  2. 选择

    SELECT 语句的一般形式如下:

    SELECT
        [DISTINCT]
        col_name, col_name, ... col_name
        FROM table_name
        [WHERE where_condition]
        [GROUP BY col_name [ASC | DESC]]
        [HAVING where_condition]
        [ORDER BY col_name [ASC | DESC]]
        [LIMIT row_count OFFSET offset]
        [INTO OUTFILE 'file_name']

    有一些技巧迟早可以派上用场。假设数据集包含几百万个数据点,而你只想知道其大致情况,则可以用 ORDER BY 命令返回随机样本:

    ORDER BY RAND();

    可以设置 LIMIT 为想要返回的样本大小:

    ORDER BY RAND() LIMIT 1000;
  3. 插入

    通过下面的语句把数据插入到新行:

    INSERT INTO tablename(col1, col2, ...) VALUES(val1, val2, ...);

    注意,如果要插入的是一行的所有列的值,而不是这些列的子集,就可以不给出列名:

    INSERT INTO tablename VALUES(val1, val2, ...);

    也可以一次插入多条记录:

    INSERT INTO tablename(col1, col2, ...) VALUES(val1, val2, ...),(val1, val2, ...),
    (val1, val2, ...);
  4. 更新

    在某些情况下,需要更改现有记录。如果需要修补错误或者更正错字,那么大多数时候可以在命令行中快速完成该操作。毫无疑问,数据科学研究人员会访问那些正在从事生产或者正在进行分析与测试的数据库,但也可能正处于临时数据库管理员(DBA)的位置。当涉及实际的用户与数据时,更新记录是很常见的。

    UPDATE table_name SET col_name = 'value' WHERE other_col_name = 'other_val';

    在数据科学领域,很难想象需要采用编程的方法来更新数据的场合。当然会有例外,例如前面提到的错字纠正或者逐渐地建立表,但是对于绝大多数情况,更新重要数据听上去像是一场灾难,尤其是当多个用户正依赖于相同数据,并且已经编写代码,随后将针对静态数据集进行分析时。

  5. 删除(数据)

    如今,存储成本很低,删除数据似乎是不必要的,但是正像 UPDATE,如果产生了错误,却不想重建整个数据库,那么删除就是有用的。通常,你依据特定标准删除记录,例如 user_idrecord_id 或者是在某个特定日期之前:

    DELETE FROM <tablename> WHERE <col_name> = 'col_value';

    另一个有用的命令是 TRUNCATE,它删除表中所有数据,但是保持表原封不动。本质上,TRUNCATE 的作用是把表擦除干净:

    TRUNCATE <tablename>;
  6. 删除(表与数据库)

    如果想删除表中所有数据以及表本身,那么必须采用 DROP 命令,该命令彻底删除表:

    DROP TABLE <tablename>;

    下面的命令删除整个数据库及其所有内容:

    DROP DATABASE <databasename>;

1.5.3 Java数据库连接

Java 数据库连接(JDBC)是连接 Java 应用与任何支持 SQL 的数据库的协议。每个数据库提供商都有独立的 JAR 文件作为 JDBC 驱动程序,在构建及运行时,必须引入该文件。不论是哪个数据库提供商,JDBC 技术都致力于在各种应用与数据库之间提供统一的接口。

  1. 连接

    使用 JDBC 连接到数据库是非常简单方便的。只需为数据库填写适当格式的 URI(统一资源标识符),通常的格式如下:

    String uri = "jdbc:<dbtype>:[location]/<dbname>?<parameters>"

    DriverManager.getConnection() 方法会抛出异常,处理该异常有两种选择。目前 Java 的做法是把连接放在 try 语句中,这称为try 语句中调用资源(try with resource)。采用这种方法,执行 try 程序段之后,连接会自动关闭,因此不必再显式调用 Connection.close() 方法。记住,如果决定把连接语句置入实际的 try 程序段中(而不是在 try 语句中),那么需要自己显式地关闭连接(很可能是在 finally 程序段中)。

    String uri = "jdbc:mysql://localhost:3306/myDB?user=root";
    try(Connection c = DriverManager.getConnection(uri)) {
        // TODO,填写自己的代码
    } catch (SQLException e) {
        System.err.println(e.getMessage());
    }

    在拥有了连接之后,有两个问题需弄清楚。

    • 在 SQL 字符串中是否有任何变量(是否会以任何方式对 SQL 字符串进行修改)?
    • 是否期望从查询中返回任何结果,而不是仅返回查询成功与否的标志?

    首先假设将要创建 Statement 对象。如果该 Statement 对象有变量(例如,将要向 SQL 语句中追加应用变量),那么使用 PreparedStatement 对象。若不希望返回任何结果,则这样做就可以了。若希望返回结果,则需要使用 ResultSets 对象存放并处理结果。

  2. SQL 语句

    执行 SQL 语句时,考虑下面的例子:

    DROP TABLE IF EXISTS data;
    CREATE TABLE IF NOT EXISTS data(
        id INTEGER PRIMARY KEY,
        yr INTEGER,
        city VARCHAR(80));
    INSERT INTO data(id, yr, city) VALUES(1, 2015, "San Francisco"),
        (2, 2014, "New York"),(3, 2012, "Los Angeles");

    本例中所有 SQL 语句都是硬编码的字符串,没有可改变的部分。它们(除了返回布尔类型的编码)没有返回任何值,可以采用下面的方法在上述 try-catch 程序段中独立地执行:

    String sql = "<sql string goes here>";
    Statement stmt = c.createStatement();
    stmt.execute(sql);
    stmt.close();
  3. 预备语句

    也可以不把所有数据硬编码到 SQL 语句中。同样,可以通过 SQL 的 WHERE 子句创建通用更新语句,更新给定 id 的记录的 city 列。尽管你可能禁不住要通过拼接来构造 SQL 字符串,但实际中不推荐这样做。任何时候,若把外部输入替换进 SQL 表达式中,就有可能受到 SQL 注入攻击。合适的方法是在 SQL 语句中采用占位符(即问号),然后使用 PreparedStatement 类适当地引用输入变量并执行查询。预备语句的优点不仅包括安全性,也包括速度快。相较于每一次插入操作,都编译一条新 SQL 语句,PreparedStatement 只需编译一次。对于大量的插入操作,这种方法可以极大地提高插入过程的效率。前面的 INSERT 语句,如果采用相应的 Java 语句,可以写成下面这样:

    String insertSQL = "INSERT INTO data(id, yr, city) VALUES(?, ?, ?)";
    PreparedStatement ps = c.prepareStatement(insertSQL);
    /* 设置每个占位符"?"的值,起始索引是1 */
    ps.setInt(1, 1);
    ps.setInt(2, 2015);
    ps.setString(3, "San Francisco");
    ps.execute();
    ps.close();

    但如果有大量数据,并且需要遍历列表,那么该如何做呢?可以采用批处理模式(batch mode)。例如,假设有一个包含 Record 对象的 List,其内容是从 CSV 导入的:

    String insertSQL = "INSERT INTO data(id, yr, city) VALUES(?, ?, ?)";
    PreparedStatement ps = c.prepareStatement(insertSQL);
    List<Record> records = FileUtils.getRecordsFromCSV();
    for(Record r: records) {
        ps.setInt(1, r.id);
        ps.setInt(2, r.year);
        ps.setString(3, r.city);
        ps.addBatch();
    }
    ps.executeBatch();
    ps.close();
  4. 结果集

    SELECT 语句会返回结果。每次编写 SELECT 语句时,都需要调用 Statement.executeQuery() 方法,而不是 execute() 方法,并且要把返回结果赋值给 ResultSet。在数据库术语中,ResultSet 是游标,它是可迭代的数据结构。Java 类 ResultSet 本身实现了 Java 接口 Iterator,从而可以使用熟悉的 while-next 循环。

    String selectSQL = "SELECT id, yr, city FROM data";
    Statement st = c.createStatement();
    ResultSet rs = st.executeQuery(selectSQL);
    while(rs.next()) {
        int id = rs.getInt("id");
        int year = rs.getInt("yr");
        String city = rs.getString("city"));
        // TODO,对每一行的值进行处理
    }
    rs.close();
    st.close();

    就像需要对文件逐行进行读取那样,你必须决定如何处理数据。你也许要把每个值存入与值同类型的数组中,或者会把每行数据存入到一个类中,再用该类来建立列表。注意,我们现在正按照数据库模式,根据列名获取列值,进而从 ResultSet 实例中提取值。可以通过从 1 开始递增列索引来实现。

1.6 通过绘图将数据可视化

在数据科学中,数据可视化是令人兴奋的重要组成部分。大量可用的有趣数据与交互式图形技术的结合,产生了令人震惊的可视化效果,将原本复杂的事情讲述得非常清楚。好多时候,可视化是所有人期待的。最重要的是,要意识到,依据所选择展示的数据片段以及所利用的图形样式,同一数据源可以讲出完全不同的事情。

记住,数据可视化必须考虑受众的需要。可视化的消费者大体可以分为三类。首先是你自己,即无所不知的专家,最有可能对数据分析或者算法开发进行快速迭代。你的需要是尽可能简单明了地、快速地看到数据。设置图形的标题、坐标轴标签、平滑、图例,还有日期格式等,这些也许并不重要,因为你自己知道要看的是什么。本质上,我们经常对数据进行绘制,就是为了快速概览数据全貌,并不关心其他人如何查看数据。

数据可视化的第二类消费者是业界专家。解决一个数据科学问题且准备好分享后,关键是要完整地标识坐标轴,为其加上有意义的描述性标题,确保每一系列的数据都用图例做了描述,并且保证所绘制的图本身能大致讲明白一件事情。即使它在视觉上不能给人留下深刻印象,但是同事和同行很可能不在乎那些中看不中用的东西,他们在意的是试图向他们传递的信息。事实上,如果可视化能够做到图形小部件清晰且效果良好的话,那么人们更加容易对一项工作的价值做出科学的评价。当然,这种格式对于数据归档也是必要的。若现在不对坐标轴进行标记,一个月后,你将记不得它们代表什么了。

数据可视化的第三类消费者是其他所有人。现在是发挥创造性以及艺术性的时候了,因为仔细选择颜色和样式可以使好的数据显得更棒。然而要小心的是,你需要花费大量的时间以及精力来为这个层次的消费者准备图形。采用 JavaFX 的额外好处是,通过鼠标选项可以实现交互性。这使得你可以构造出图形应用,它类似于人们所习惯的众多基于 Web 的仪表盘。

1.6.1 创建简单图形

在 JavaFX 包中,Java 自带图形能力。从 1.8 版本起,借助于 javafx.scene.chart 包,可以用多种类型的图表进行科学绘图,例如散点图、折线图、条形图、堆叠条形图、饼图、面积图、堆叠面积图或者气泡图。Stage 对象包含 Scene 对象,Scene 对象包含 Chart 对象。一般形式是采用 Application 扩展可执行的 Java 类,并把所有绘制命令放在重写的 Application.start() 方法中。必须在 main 方法中调用 Application.launch() 方法,以创建并显示图表。

  1. 散点图的绘制

    简单绘图的一个例子是散点图,它把一系列 x-y 对的数字绘制成网格上的点。这些图利用了 javafx.scene.chart.XYChart.Datajavafx.scene.chart.XYChart.Series 两个类。Data 类是容器,它可容纳任何规模的混合类型的数据,Series 类包含内容为 Data 实例的 ObservableList。在 javafx.collections.FXCollections 类中有一些工厂方法,如果愿意的话,可以用这些方法直接创建 ObservableList 的实例。不过,对于散点图、折线图、面积图、气泡图以及条形图,这是不必要的,因为它们都利用了 Series 类。

    public class BasicScatterChart extends Application {
    
        public static void main(String[] args) {
            launch(args);
        }
    
        @Override
        public void start(Stage stage) throws Exception {
            int[] xData = {1, 2, 3, 4, 5};
            double[] yData = {1.3, 2.1, 3.3, 4.0, 4.8};
    
            /* 将Data添加到Series中 */
            Series series = new Series();
            for (int i = 0; i < xData.length; i++) {
                series.getData().add(new Data(xData[i], yData[i]));
            }
    
            /* 定义坐标轴 */
            NumberAxis xAxis = new NumberAxis();
            xAxis.setLabel("x");
            NumberAxis yAxis = new NumberAxis();
            yAxis.setLabel("y");
    
            /* 创建散点图 */
            ScatterChart<Number,Number> scatterChart =
                new ScatterChart<>(xAxis, yAxis);
            scatterChart.getData().add(series);
    
            /* 采用图创建场景 */
            Scene scene = new Scene(scatterChart, 800, 600);
    
            /* 告诉舞台(stage)采用什么场景(scene)并对其进行绘制 */
            stage.setScene(scene);
            stage.show();
        }
    
    }

    图 1-1 是用 JavaFX 绘制简单数据集后的默认图形窗口。

    {%}

    图 1-1:散点图示例

    在前面的例子中,可以方便地采用 LineChartAreaChart 或者 BubbleChart 类替换 ScatterChart 类。

  2. 条形图

    作为 x-y 图,条形图利用了 DataSeries 类。然而,在这种情况下,唯一的区别是,x 轴必须是字符串类型(而不是数值类型),并且利用 CategoryAxis 类,而不是 NumberAxis 类。y 轴仍然利用 NumberAxis 类。通常,条形图的分类就像一周中的每一天,或者市场的细分。注意,BarChart 类内部采用的是 (String, Number) 对的类型,这对生成直方图是有用的(第 3 章会展示一个直方图)。

    public class BasicBarChart extends Application {
    
        public static void main(String[] args) {
            launch(args);
        }
    
        @Override
        public void start(Stage stage) throws Exception {
    
            String[] xData = {"Mon", "Tues", "Wed", "Thurs", "Fri"};
            double[] yData = {1.3, 2.1, 3.3, 4.0, 4.8};
    
            /* 把Data添加到Series中 */
            Series series = new Series();
            for (int i = 0; i < xData.length; i++) {
                series.getData().add(new Data(xData[i], yData[i]));
            }
    
            /* 定义坐标 */
            CategoryAxis xAxis = new CategoryAxis();
            xAxis.setLabel("x");
            NumberAxis yAxis = new NumberAxis();
            yAxis.setLabel("y");
    
            /* 创建条形图 */
            BarChart<String,Number> barChart = new barChart<>(xAxis, yAxis);
            barChart.getData().add(series);
     
     
            /* 采用图创建场景 */
            Scene scene = new Scene(barChart, 800, 600);
    
            /* 告诉舞台(stage)采用什么场景(scene)并对其进行绘制 */
            stage.setScene(scene);
            stage.show();
        }
    
    }
  3. 绘制多个系列

    可以很容易地实现对任何图形类型的多个系列进行绘制。对于散点图的示例,只需要创建多个 Series 实例:

    Series series1 = new Series();
    Series series2 = new Series();
    Series series3 = new Series();

    然后,用 addAll() 方法,而不是用 add() 方法,可以一次添加所有这些系列:

    scatterChart.getData().addAll(series1, series2, series3);

    从所产生的图中可以看出,这些点以各种颜色重叠在一起,每一种颜色以各自的图例来指示其标签名。这对于折线图、面积图、条形图、气泡图同样适用。对于 StackedAreaChartStackedBarChart 这两个类,它们的一个特点值得我们关注,那就是除了一个数据是堆叠在另一个数据之上的,以免在视觉上相互遮挡外,它们运行的方式与其各自的超类 AreaChartBarChart 相同。

    当然,有时候,采用多种类型的图对数据进行混合绘制,可视化效果会更好,例如对同一数据同时采用散点图与折线图。当前,Scene 类只接受同一种类型的图表。不过,本章随后将给出一些变通方法。

  4. 基本格式

    有一些有用的选项,可以使图看起来更专业。首先需要清理的地方可能是坐标轴。通常,刻度线太小会适得其反。也可以通过设置最小值与最大值来界定图的范围。

    scatterChart.setBackground(null);
    scatterChart.setLegendVisible(false);
    scatterChart.setHorizontalGridLinesVisible(false);
    scatterChart.setVerticalGridLinesVisible(false);
    scatterChart.setVerticalZeroLineVisible(false);

    在某个阶段,保持绘制方法简洁,并把所有样式说明包含在 CSS(层叠样式表)文件中,也许会更容易一些。JavaFX 8 中默认的 CSS 称作 Modena,若不修改样式选项,则会应用 Modena 文件。也可以创建自己的 CSS,并采用下面的方法将其包含在场景中:

    scene.getStylesheets().add("chart.css");

    默认路径是自己 Java 包的 src/main/resources 目录。

1.6.2 混合类型图的绘制

我们经常想要在同一个图形中显示多个不同类型的图。例如,有时需要把数据点显示为 x-y 散点图,然后在散点图上覆盖一个采用最佳拟合模型产生的折线图。也许还想在图中包含另外两条折线,用以表示模型的边界,可能是一倍、两倍或三倍的标准差 \sigma,也可能是置信区间 1.96\times\sigma。目前,JavaFX 不允许在同一场景中同时显示多个不同类型的图。不过,有个变通方法:可以用 LineChart 类绘制多个系列的 LineChart 实例,然后用 CSS 设置其样式为:第一条折线只显示点,第二条折线只显示实线,再有两条折线只显示虚线。

CSS 如下:

.default-color0.chart-series-line {
    -fx-stroke: transparent;
}

.default-color1.chart-series-line {
    -fx-stroke: blue; -fx-stroke-width: 1;
}

.default-color2.chart-series-line {
    -fx-stroke: blue;
    -fx-stroke-width: 1;
    -fx-stroke-dash-array: 1 4 1 4;
}

.default-color3.chart-series-line {
    -fx-stroke: blue;
    -fx-stroke-width: 1;
    -fx-stroke-dash-array: 1 4 1 4;
}

/*.default-color0.chart-line-symbol {
    -fx-background-color: white, green;
}*/

.default-color1.chart-line-symbol {
    -fx-background-color: transparent, transparent;
}

.default-color2.chart-line-symbol {
    -fx-background-color: transparent, transparent;
}

.default-color3.chart-line-symbol {
    -fx-background-color: transparent, transparent;
}

绘图如图 1-2 所示。

{%}

图 1-2:采用 CSS 实现混合折线类型的绘制

1.6.3 把图存入文件

毫无疑问,有时候需要把图存入文件中。你也许会在电子邮件中发送该图,或者把它包含在演示文稿中。综合采用标准 Java 类与 JavaFX 类,可以很容易地以任何格式保存图。采用 CSS,甚至可以使所绘制的图成为具有出版质量的图形。事实上,本章(以及书中其他部分)的图就是用这种方式生成的。

每一种图表类都是抽象类 Chart 的子类,ChartNode 类中继承了 snapshot() 方法。Chart.snapshot() 方法返回 WritableImage 类。有个潜在的不利因素需要指出:采用场景将数据绘制到图表上时,保存图的文件中将不会有该图上的实际数据。在实例化图表之后,在采用 Chart.getData.add() 或其他等效方法把数据添加到图表之前,通过 Chart.setAnimated(false) 关掉动画是极其重要的。

/* 把图表实例化之后,立刻进行该操作 */
scatterChart.setAnimated(false);
...
/* 绘制图片 */
stage.show();
...
/* 绘制舞台之后,再把图保存到文件中 */
WritableImage image = scatterChart.snapshot(new SnapshotParameters(), null);
File file = new File("chart.png");
ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", file);

 本书中的所有数据图都是采用 JavaFX 8 绘制的。

目录