4.4.2.例子:词频统计
文本分析具有多种用途,从数据挖掘到语言学习本身。在本小节中,我们将讲解一个文本分析最基本形式的例子:从给定的文件中统计单词出现的频度。
频度数据可以以两种不同却同样合理的方式显示,一种是将单词以字母顺序排列,另一种是按照频度排列。wordfrequency程序(在文件wordfrequency/wordfrequency.go里)产生两种类型的输出,如下所示。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | $ ./wordfrequency small-file.txt Word Frequency ability 1 about 1 above 3 ... years 1 you 128 Frequency → Words 1 ability, about, absence, absolute, absolutely, abuse, accessible, ... 2 accept, acquired, after, against, applies, arrange, assumptions, ... ... 128 you 151 or 192 to 221 of 345 the |
即使是一个很小的文件,单词的数量及其频度也可以非常大,所以这里我们忽略了大部分结果输出。
第一种输出比较简单,我们可以使用一个map[string]int类型的映射来保存每个单词及其频度。但是要得到第二种输出我们需要进行映射反转,但是操作起来并没有那么容易,因为很可能不止一个单词具有相同的频度,解决的方法就是将其反转为map[int][]string类型的具有多个值的映射,也就是说,映射的键是频度,值是具有这个频度的所有单词。
我们将从程序的main()函数开始,从上到下分析,并和往常一样,忽略掉import部分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | func main() { if len(os.Args) == 1 || os.Args[1] == "-h" || os.Args[1] == "--help" { fmt.Printf("usage: %s <file1> [<file2> [... <fileN>]]\n", filepath.Base(os.Args[0])) os.Exit(1) } frequencyForWord := map[string]int{} // Same as: make(map[string]int) for _, filename := range commandLineFiles(os.Args[1:]) { updateFrequencies(filename, frequencyForWord) } reportByWords(frequencyForWord) wordsForFrequency := invertStringIntMap(frequencyForWord) reportByFrequency(wordsForFrequency) } |
main()函数首先处理命令行参数。
我们开始创建了一个简单结构的映射,用于保存从文件读到的每一个单词及其对应的频度。这里我们使用复合语法创建了一个初始值为空的映射,仅仅是为了演示如何创建映射。一旦映射创建完成,我们就遍历命令行中指定的每一个文件,并更新frequencyForword映射。
一旦第一个映射填充完成,我们就输出第一个报告:按照字母表顺序排序的单词列表及其对应的频度。然后将第一个映射反转并输出第二个报告:按照频度排序的频率值及其对应的单词。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | func commandLineFiles(files []string) []string { if runtime.GOOS == "windows" { args := make([]string, 0, len(files)) for _, name := range files { if matches, err := filepath.Glob(name); err != nil { args = append(args, name) // Invalid pattern } else if matches != nil { // At least one match args = append(args, matches...) } } return args } return files } |
commandLineFiles()函数会简单的返回一个[]string类型的值,就像类Unix平台所操作的的那样,如Linux和Mac OS X,因为在这些平台上shell会自动做文件通配符匹配(例如,*.txt会匹配任意文本文件,如README.txt,INSTALL.txt等等)。而Windows的shell(cmd.exe)不会做通配符匹配,所以如果用户在命令行输入*.txt,也就是说程序只能接收到名为*.txt的文件。为了保证平台之间的一致性,当程序运行在Windows平台时,我们自己处理通配符。(另一种跨平台的办法是不同的平台使用不同的.go文件。)
1 2 3 4 5 6 7 8 9 10 | func updateFrequencies(filename string, frequencyForWord map[string]int) { var file *os.File var err error if file, err = os.Open(filename); err != nil { log.Println("failed to open the file: ", err) return } defer file.Close() readAndUpdateFrequencies(bufio.NewReader(file), frequencyForWord) } |
上面的函数纯粹是用于处理文件的。它打开给定的文件,并在当函数返回时关闭文件,接下来调用readAndUpdateFrequencies()函数继续处理,通过传递*bufio.Reader(通过调用bufio.NewReader()产生),我们可以确保函数是以字符串的形式一行一行地读取数据的而不是读取原生字节。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | func readAndUpdateFrequencies(reader *bufio.Reader, frequencyForWord map[string]int) { for { line, err := reader.ReadString('\n') for _, word := range SplitOnNonLetters(strings.TrimSpace(line)) { if len(word) > utf8.UTFMax || utf8.RuneCountInString(word) > 1 { frequencyForWord[strings.ToLower(word)] += 1 } } if err != nil { if err != io.EOF { log.Println("failed to finish reading the file: ", err) } break } } } |
现在,函数前半部分的代码我们应该很熟悉。我们使用了一个无限循环逐行读取文件,当读到文件结尾或者出现错误(这种情况下我们将错误报告给用户)时退出循环,但是在遇到错误时,我们并不终止程序执行,因为可能还有很多其他的文件需要去处理,我们通常希望抛出出现的任何异常而不是在第一个错误上就终止程序运行。内层的for循环才是我们感兴趣的地方,给定的任何行都可能包含标点、数字、符号或者其他非单词字符,所以我们将该行分割成单个的单词,然后遍历这些单词并调用自定义的SplitOnNonLetters()函数丢弃所有非单词的字符,而且我们在传递参数给该函数时就去除了字符串开头和结束处的空格。
如果我们只需要至少含有两个字母的单词,最简单的办法就是只使用一个if语句,即if utf8.RuneCountInString(word)>1。
刚才描述的那个简单的if语句执行成本可能有点高,因为它会分析整个单词。所以这里我们用了一个含有两个判断条件的if语句,第一个条件比较高效,它检查这个单词的字节数是否大于utf8.UTFMax(它是一个值为4的常量,表示一个UTF-8字符占用的字节数)。这种判断方法是非常快的,因为Go语言的strings知道它们包含了多少个字节且其布尔操作符(&& 和||)是短路操作。当然,如果单词的字节数(例如,4个7位ASCII字符或一对2位的UTF-8字符)少于等于4的话,第一个判断为false,不过没关系,第二次检查(rune的个数)非常高效,因为其所要计算的字符数不会大于4个。在这个程序里我们需要使用两个判断条件吗?这就取决于用户输入,需要处理的单词越多,长度越长,其效率就可能越高。唯一可以确切知道的办法就是使用真实或至少是典型的数据集来做基准测试。
1 2 3 4 | func SplitOnNonLetters(s string) []string { notALetter := func(char rune) bool { return !unicode.IsLetter(char) } return strings.FieldsFunc(s, notALetter) } |
上面的函数用于在非单词字符上切分字符串。首先我们创建了一个具有strings.FieldsFunc()函数所需签名函数的匿名函数,对于非字母字符该函数返回true,否则返回false。然后我们返回以给定的字符串和notALetter函数为参数的strings.FieldsFunc()函数的结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | func reportByWords(frequencyForWord map[string]int) { words := make([]string, 0, len(frequencyForWord)) wordWidth, frequencyWidth := 0, 0 for word, frequency := range frequencyForWord { words = append(words, word) if width := utf8.RuneCountInString(word); width > wordWidth { wordWidth = width } if width := len(fmt.Sprint(frequency)); width > frequencyWidth { frequencyWidth = width } } sort.Strings(words) gap := wordWidth + frequencyWidth - len("Word") - len("Frequency") fmt.Printf("Word %*s%s\n", gap, " ", "Frequency") for _, word := range words { fmt.Printf("%-*s %*d\n", wordWidth, word, frequencyWidth, frequencyForWord[word]) } } |
一旦映射frequencyForWord填充完成,就调用reportByWords()函数将其内容输出。因为我们希望输出结果以字母顺序排序(实际上是以Unicode码点顺序),所以我们首先创建了一个空的[]string切片来保存frequencyForWord中的所有单词。同时我们也希望能知道最长的单词和最高的频度的字符宽度(即,数字的位数)以便于我们可以以整齐的方式输出结果:使用wordWidth和frequencyWidth这两个变量来记录其宽度。
第一个for循环遍历映射里的所有元素,每个单词都被追加到words []string切片中,这个操作非常高效,因为words的容量足够大,append()函数所需要做的只是把给定的单词追加到len(words)索引位置,并将该切片的长度加1。
对于每一个单词,我们都计算出它所包含的字符个数,如果这个值大于已存在的值就将wordWidth设置为这个值。同理,我们计算出要表示一个频度所需要的字符数,我们可以安全地使用len()函数来计算出其字节数,因为fmt.Sprint()函数接受一个数字作为参数并返回一个7位ASCII的字符串。这样在第一个循环结束时,我们就得到了我们想要的两列数据的宽度。
一旦我们将words切片填充完毕,我们就对它进行排序,我们不必担心是否区分大小写,因为所有的单词都是小写的(这个操作已在readAndUpdateFrequencies()函数中完成)。
将单词排序后,我们打印出两列标题,首先我们打印出的是“Word”,然后打印一系列空格来让“Frequency”的最后一个字符y与频度的最后一个数字右对齐。这是通过使用%*s格式化说明符以固定长度打印出来的。另一种方法是使用普通的%s格式来打印strings.Repeat(” “, gap)返回的空格字符串。
最后,我们以合适的宽度将单词和它们的频度用两列按照字母顺序打印出来。
1 2 3 4 5 6 7 | func invertStringIntMap(intForString map[string]int) map[int][]string { stringsForInt := make(map[int][]string, len(intForString)) for key, value := range intForString { stringsForInt[value] = append(stringsForInt[value], key) } return stringsForInt } |
的函数首先创建了一个空的反转的映射。虽然我们不知道该映射需要保存多少个元素,我们就先假定它和原来的映射具有相同的长度,毕竟不可能比原来的多。接下来的处理就比较简单了:遍历原来的映射,将其值作为键保存到反转的映射里,并将键作为值保存到对应的值中,因为新的映射的值是切片类型,即使原来的映射多个键具有相同的值,也不会丢失数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | func reportByFrequency(wordsForFrequency map[int][]string) { frequencies := make([]int, 0, len(wordsForFrequency)) for frequency := range wordsForFrequency { frequencies = append(frequencies, frequency) } sort.Ints(frequencies) width := len(fmt.Sprint(frequencies[len(frequencies)-1])) fmt.Println("Frequency → Words") for _, frequency := range frequencies { words := wordsForFrequency[frequency] sort.Strings(words) fmt.Printf("%*d %s\n", width, frequency, strings.Join(words, ", ")) } } |
上面的函数和reportByWords()函数的结构非常相似。它首先创建一个frequencies切片,并以升序排列,然后计算出最大频度的长度并作为第一列的宽度,接下来输出报告的标题。最后,遍历frequencies切片并输出所有的频度和对应的以字母升序排序的单词,如果一个频度对应超过一个单词,则单词之间用逗号分隔。
现在我们讲解完了这章的两个完整示例,并深入介绍了Go语言指针的使用,特别是Go语言中切片和映射类型的强大和便利。在下一章,我们将讲解如何创建自定义函数,并结束Go语言过程式编程的基础知识介绍。之后的章节我们将继续讲解Go语言的面向对象编程和并发编程。