Chapter 53Project Top K Word Frequency Analyzer

项目

概览

调试章节介绍了用于解释程序为什么表现不佳的工具。52 这个项目依赖于类似的规范来构建确定性文本分析工具:向其提供日志摘录,收集最频繁的标记,并发出每个阶段的时序数据。我们将结合 std.mem 的分词助手、std 的哈希集合、heap 排序器和 Timer API,以产生可重现的排名和可测量的成本。mem.zighash_map.zigsort.zigtime.zig

学习目标

  • 构建端到端 I/O 管道,读取语料库、规范化文本,并使用 std.StringHashMapstd.ArrayList 累积计数。array_list.zig
  • 使用显式比较器确定性地排名频率,而不依赖于哈希映射迭代顺序来解析平局。
  • 使用 std.time.Timer 捕获每个阶段的时序,以验证回归并传达性能期望。

设计管道

我们的分析器接受可选的文件路径和可选的 k 参数(前 k 个令牌);两者分别默认为捆绑语料库和 5。为简单起见,我们将整个文件读入内存,但规范化计数循环编写为线性操作,因此以后可以适应流块。GeneralPurposeAllocator 支持所有动态结构,arena 友好工作流(仅在首次出现时复制字符串)使分配与词汇表大小成正比。heap.zigprocess.zig

分词通过 std.mem.tokenizeAny 进行,配置了保守的分隔符集,用于修剪空白、标点符号和标记字符。每个令牌在尝试插入映射之前在可重用的 std.ArrayList(u8) 中小写化;如果令牌已存在,则仅增加计数,保持临时分配有界。

计数和排名

完整的实用程序并排演示了 StringHashMap、ArrayList、排序和时序。

Zig
const std = @import("std");

const Separators = " \t\r\n,.;:!?\"'()[]{}<>-/\\|`~*_";

const Entry = struct {
    word: []const u8,
    count: usize,
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer if (gpa.deinit() == .leak) @panic("memory leak");
    const allocator = gpa.allocator();

    var stdout_buffer: [256]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const out = &stdout_writer.interface;

    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    const path = if (args.len >= 2) args[1] else "chapters-data/code/53__project-top-k-word-frequency-analyzer/sample_corpus.txt";
    const top_k: usize = blk: {
        if (args.len >= 3) {
            const value = std.fmt.parseInt(usize, args[2], 10) catch {
                try out.print("invalid top-k value: {s}\n", .{args[2]});
                return error.InvalidArgument;
            };
            break :blk if (value == 0) 1 else value;
        }
        break :blk 5;
    };

    var timer = try std.time.Timer.start();

    const corpus = try std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024 * 4);
    defer allocator.free(corpus);
    const read_ns = timer.lap();

    var scratch = try std.ArrayList(u8).initCapacity(allocator, 32);
    defer scratch.deinit(allocator);

    var frequencies = std.StringHashMap(usize).init(allocator);
    defer frequencies.deinit();

    var total_tokens: usize = 0;

    var it = std.mem.tokenizeAny(u8, corpus, Separators);
    while (it.next()) |raw| {
        scratch.clearRetainingCapacity();
        try scratch.appendSlice(allocator, raw);
        const slice = scratch.items;
        for (slice) |*byte| {
            byte.* = std.ascii.toLower(byte.*);
        }
        if (slice.len == 0) continue;

        const gop = try frequencies.getOrPut(slice);
        if (gop.found_existing) {
            gop.value_ptr.* += 1;
        } else {
            const owned = try allocator.dupe(u8, slice);
            gop.key_ptr.* = owned;
            gop.value_ptr.* = 1;
        }
        total_tokens += 1;
    }
    const tokenize_ns = timer.lap();

    var entries = try std.ArrayList(Entry).initCapacity(allocator, frequencies.count());
    defer {
        for (entries.items) |entry| allocator.free(entry.word);
        entries.deinit(allocator);
    }

    var map_it = frequencies.iterator();
    while (map_it.next()) |kv| {
        try entries.append(allocator, .{ .word = kv.key_ptr.*, .count = kv.value_ptr.* });
    }

    const entry_slice = entries.items;
    std.sort.heap(Entry, entry_slice, {}, struct {
        fn lessThan(_: void, a: Entry, b: Entry) bool {
            if (a.count == b.count) return std.mem.lessThan(u8, a.word, b.word);
            return a.count > b.count;
        }
    }.lessThan);
    const sort_ns = timer.lap();

    const unique_words = entries.items.len;
    const limit = if (unique_words < top_k) unique_words else top_k;

    try out.print("source -> {s}\n", .{path});
    try out.print("tokens -> {d}, unique -> {d}\n", .{ total_tokens, unique_words });
    try out.print("top {d} words:\n", .{limit});
    var index: usize = 0;
    while (index < limit) : (index += 1) {
        const entry = entry_slice[index];
        try out.print("  {d:>2}. {s} -> {d}\n", .{ index + 1, entry.word, entry.count });
    }

    try out.print("timings (ns): read={d}, tokenize={d}, sort={d}\n", .{ read_ns, tokenize_ns, sort_ns });
    try out.flush();
}
运行
Shell
$ zig run chapters-data/code/53__project-top-k-word-frequency-analyzer/topk_word_frequency.zig
输出
Shell
source -> chapters-data/code/53__project-top-k-word-frequency-analyzer/sample_corpus.txt
tokens -> 102, unique -> 86
top 5 words:
   1. the -> 6
   2. a -> 3
   3. and -> 3
   4. are -> 2
   5. latency -> 2
timings (ns): read=284745, tokenize=3390822, sort=236725

std.StringHashMap 存储规范的小写拼写,另一个 std.ArrayList 收集最终的 (word, count) 对进行排序。我们选择 std.sort.heap,因为它是确定性的,没有分配器依赖,并且在小型数据集上表现良好;比较器主要按降序计数排序,其次按词典顺序排序以保持平局稳定。当跨运行或机器重新运行分析时,这很重要——现场团队可以差异产生的 CSV 而不会感到惊讶。

时序和可重现性

单个 Timer 实例测量三个阶段:文件摄取、分词和排序。我们每个阶段后调用 lap() 来重置零点,同时记录经过的纳秒数,轻松发现哪个步骤占主导地位。因为分析器规范化大小写并使用确定性排序,给定语料库的输出在运行中保持相同,允许将时序差异归因于硬件或工具链变化而不是非确定性排序。

对于回归测试,使用更大的 k 或不同的语料库重新运行:

Shell
$ zig run chapters-data/code/53__project-top-k-word-frequency-analyzer/topk_word_frequency.zig -- chapters-data/code/53__project-top-k-word-frequency-analyzer/sample_corpus.txt 10

可选参数让您保持二进制文件的可脚本化——将其放入 CI,比较输出工件,并在时序预算变化超过阈值时发出警报。当集成到更大的系统中时,映射构建循环可以交换为从 stdin 或 TCP 套接字流式传输,同时保持相同的确定性排名规则。File.zig

Notes & Caveats

  • StringHashMap 不会自动释放存储的键;此示例在删除映射之前显式释放它们,以保持通用分配器泄漏检查器满意。
  • 分词器专注于 ASCII。对于完整的 Unicode 分段,将管道与 std.unicode.ScalarIterator 配对或集成 ICU 绑定。45
  • 将整个语料库读入内存简化了教程,但可能不适合多千兆字节的日志。在扩展时将 readFileAlloc 交换为分块的 readAll 循环或内存映射文件。28

练习

  • 通过序列化排序的切片以 JSON 格式发出报告,然后比较与文本版本的差异友好性。32json.zig
  • 用两阶段管道替换单线程分析器:跨线程分片令牌,然后在排序前合并哈希映射。使用 Timer 测量收益并总结扩展性。29
  • 添加一个 --stopwords 选项,该选项加载一个换行符分隔的忽略列表,在计数前删除这些令牌,并报告过滤掉了多少候选对象。36

Caveats, Alternatives, Edge Cases

  • 对于流式环境,考虑使用 std.PriorityQueue 以增量方式维护前 k 个,而不是在排序前记录整个直方图。priority_queue.zig
  • 如果性能需求超出堆排序,请尝试 std.sort.pdq 或基于桶的方法,同时保持确定性比较器契约完整。
  • 为了支持多语言文本,叠加规范化(NFC/NFKC)并使用 Unicode 感知的大小写助手;比较器可能需要特定于语言环境的排序以保持结果直观。45

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.