Run this notebook online:Binder or Colab: Colab

8.2. 文本预处理

对于序列数据处理问题,我们在 Section 8.1中 评估了所需的统计工具和预测时面临的挑战。 这样的数据存在许多种形式,文本是最常见例子之一。 例如,一篇文章可以被简单地看作是一串单词序列,甚至是一串字符序列。 本节中,我们将解析文本的常见预处理步骤。 这些步骤通常包括:

  1. 将文本作为字符串加载到内存中。

  2. 将字符串拆分为词元(如单词和字符)。

  3. 建立一个词表,将拆分的词元映射到数字索引。

  4. 将文本转换为数字索引序列,方便模型操作。

%load ../utils/djl-imports

8.2.1. 读取数据集

首先,我们从H.G.Well的时光机器中加载文本。 这是一个相当小的语料库,只有30000多个单词,但足够我们小试牛刀, 而现实中的文档集合可能会包含数十亿个单词。 下面的函数将数据集读取到由多条文本行组成的列表中,其中每条文本行都是一个字符串。 为简单起见,我们在这里忽略了标点符号和字母大写。

public String[] readTimeMachine() throws IOException {
    URL url = new URL("http://d2l-data.s3-accelerate.amazonaws.com/timemachine.txt");
    String[] lines;
    try (BufferedReader in = new BufferedReader(new InputStreamReader(url.openStream()))) {
        lines = in.lines().toArray(String[]::new);
    }

    for (int i = 0; i < lines.length; i++) {
        lines[i] = lines[i].replaceAll("[^A-Za-z]+", " ").strip().toLowerCase();
    }
    return lines;
}

String[] lines = readTimeMachine();
System.out.println("# text lines: " + lines.length);
System.out.println(lines[0]);
System.out.println(lines[10]);
# text lines: 3221
the time machine by h g wells
twinkled and his usually pale face was flushed and animated the

8.2.2. 词元化

下面的tokenize函数将文本行列表(lines)作为输入, 列表中的每个元素是一个文本序列(如一条文本行)。 每个文本序列又被拆分成一个词元列表,词元(token)是文本的基本单位。 最后,返回一个由词元列表组成的列表,其中的每个词元都是一个字符串(string)。

public String[][] tokenize(String[] lines, String token) throws Exception {
    // 将文本行拆分为单词或字符标记
    String[][] output = new String[lines.length][];
    if (token == "word") {
        for (int i = 0; i < output.length; i++) {
            output[i] = lines[i].split(" ");
        }
    } else if (token == "char") {
        for (int i = 0; i < output.length; i++) {
            output[i] = lines[i].split("");
        }
    } else {
        throw new Exception("ERROR: unknown token type: " + token);
    }
    return output;
}
String[][] tokens = tokenize(lines, "word");
for (int i = 0; i < 11; i++) {
    System.out.println(Arrays.toString(tokens[i]));
}
[the, time, machine, by, h, g, wells]
[]
[]
[]
[]
[i]
[]
[]
[the, time, traveller, for, so, it, will, be, convenient, to, speak, of, him]
[was, expounding, a, recondite, matter, to, us, his, grey, eyes, shone, and]
[twinkled, and, his, usually, pale, face, was, flushed, and, animated, the]

8.2.3. 词汇表

词元的类型是字符串,而模型需要的输入是数字,因此这种类型不方便模型使用。 现在,让我们构建一个字典,通常也叫做词汇表(vocabulary), 用来将字符串类型的词元映射到从\(0\)开始的数字索引中。 我们先将训练集中的所有文档合并在一起,对它们的唯一词元进行统计, 得到的统计结果称之为语料(corpus)。 然后根据每个唯一词元的出现频率,为其分配一个数字索引。 很少出现的词元通常被移除,这可以降低复杂性。 另外,语料库中不存在或已删除的任何词元都将映射到一个特定的未知词元“<unk>”。 我们可以选择增加一个列表,用于保存那些被保留的词元, 例如:填充词元(“<pad>”); 序列开始词元(“<bos>”); 序列结束词元(“<eos>”)。

public class Vocab {
    public int unk;
    public List<Map.Entry<String, Integer>> tokenFreqs;
    public List<String> idxToToken;
    public HashMap<String, Integer> tokenToIdx;

    public Vocab(String[][] tokens, int minFreq, String[] reservedTokens) {
        // 按频率排序
        LinkedHashMap<String, Integer> counter = countCorpus2D(tokens);
        this.tokenFreqs = new ArrayList<Map.Entry<String, Integer>>(counter.entrySet());
        Collections.sort(tokenFreqs,
            new Comparator<Map.Entry<String, Integer>>() {
                public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {
                    return (o2.getValue()).compareTo(o1.getValue());
                }
            });

        // 未知标记的索引为0
        this.unk = 0;
        List<String> uniqTokens = new ArrayList<>();
        uniqTokens.add("<unk>");
        Collections.addAll(uniqTokens, reservedTokens);
        for (Map.Entry<String, Integer> entry : tokenFreqs) {
            if (entry.getValue() >= minFreq && !uniqTokens.contains(entry.getKey())) {
                uniqTokens.add(entry.getKey());
            }
        }

        this.idxToToken = new ArrayList<>();
        this.tokenToIdx = new HashMap<>();
        for (String token : uniqTokens) {
            this.idxToToken.add(token);
            this.tokenToIdx.put(token, this.idxToToken.size()-1);
        }
    }

    public int length() {
        return this.idxToToken.size();
    }

    public Integer[] getIdxs(String[] tokens) {
        List<Integer> idxs = new ArrayList<>();
        for (String token : tokens) {
            idxs.add(getIdx(token));
        }
        return idxs.toArray(new Integer[0]);

    }

    public Integer getIdx(String token) {
        return this.tokenToIdx.getOrDefault(token, this.unk);
    }


}

public LinkedHashMap<String, Integer> countCorpus(String[] tokens) {
    /* 计算token频率 */
    LinkedHashMap<String, Integer> counter = new LinkedHashMap<>();
    if (tokens.length != 0) {
        for (String token : tokens) {
            counter.put(token, counter.getOrDefault(token, 0)+1);
        }
    }
    return counter;
}

public LinkedHashMap<String, Integer> countCorpus2D(String[][] tokens) {
    /* 将token列表展平为token列表*/
    List<String> allTokens = new ArrayList<String>();
    for (int i = 0; i < tokens.length; i++) {
        for (int j = 0; j < tokens[i].length; j++) {
             if (tokens[i][j] != "") {
                allTokens.add(tokens[i][j]);
             }
        }
    }
    return countCorpus(allTokens.toArray(new String[0]));
}

我们使用时间机器数据集作为语料库构建了一个词汇表。 然后,我们打印前几个频繁标记及其索引。

Vocab vocab = new Vocab(tokens, 0, new String[0]);
for (int i = 0; i < 10; i++) {
    String token = vocab.idxToToken.get(i);
    System.out.print("(" + token + ", " + vocab.tokenToIdx.get(token) + ") ");
}
(<unk>, 0) (the, 1) (i, 2) (and, 3) (of, 4) (a, 5) (to, 6) (was, 7) (in, 8) (that, 9)

现在我们可以将每一行文本转换为一个数字索引列表。

for (int i : new int[] {0,10}) {
    System.out.println("Words:" + Arrays.toString(tokens[i]));
    System.out.println("Indices:" + Arrays.toString(vocab.getIdxs(tokens[i])));
}
Words:[the, time, machine, by, h, g, wells]
Indices:[1, 19, 50, 40, 2183, 2184, 400]
Words:[twinkled, and, his, usually, pale, face, was, flushed, and, animated, the]
Indices:[2186, 3, 25, 1044, 362, 113, 7, 1421, 3, 1045, 1]

8.2.4. 把所有的东西放在一起

使用上述函数,我们将所有内容打包到loadCorpusTimeMachine函数中, 该函数返回corpus,一个标记索引列表,以及vocab,时间机器语料库的词汇表。 我们在这里做的修改是: 一) 我们将文本标记为字符,而不是单词,以简化后面章节中的训练; 二)corpus 是一个单一的列表,而不是标记列表列表,因为时间机器数据集中的每一行文本不一定是一个句子或段落。

public Pair<List<Integer>, Vocab> loadCorpusTimeMachine(int maxTokens) throws IOException, Exception {
    /* 返回时间机器数据集的令牌索引和词汇表。 */
    String[] lines = readTimeMachine();
    String[][] tokens = tokenize(lines, "char");
    Vocab vocab = new Vocab(tokens, 0, new String[0]);
    // 因为时间机器数据集中的每个文本行不一定是
    // 句子或段落,将所有文本行展平为一个列表
    List<Integer> corpus = new ArrayList<>();
    for (int i = 0; i < tokens.length; i++) {
        for (int j = 0; j < tokens[i].length; j++) {
            if (tokens[i][j] != "") {
                corpus.add(vocab.getIdx(tokens[i][j]));
            }
        }
    }
    if (maxTokens > 0) {
        corpus = corpus.subList(0, maxTokens);
    }
    return new Pair(corpus, vocab);
}

Pair<List<Integer>, Vocab> corpusVocabPair = loadCorpusTimeMachine(-1);
List<Integer> corpus = corpusVocabPair.getKey();
Vocab vocab = corpusVocabPair.getValue();

System.out.println(corpus.size());
System.out.println(vocab.length());
170580
28

8.2.5. 小结

  • 文本是序列数据的一种最常见的形式之一。

  • 为了对文本进行预处理,我们通常将文本拆分为词元,构建词表将词元字符串映射为数字索引,并将文本数据转换为词元索引以供模型操作。

8.2.6. 练习

  1. 词元化是一个关键的预处理步骤,它因语言而异。尝试找到另外三种常用的词元化文本的方法。

  2. 在本节的实验中,将文本词元为单词和更改Vocab实例的minFreq参数。这对词汇表大小有何影响?