twpipeline 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +21 -0
- data/README.md +236 -0
- data/bin/twpipeline +15 -0
- data/data/hplt/README.md +26 -0
- data/data/hplt/taiwan_domains.candidates.txt +300 -0
- data/data/hplt/taiwan_domains.txt +0 -0
- data/lib/twpipeline/bench.rb +55 -0
- data/lib/twpipeline/cli.rb +144 -0
- data/lib/twpipeline/frequency.rb +48 -0
- data/lib/twpipeline/jsonl.rb +30 -0
- data/lib/twpipeline/parallel.rb +92 -0
- data/lib/twpipeline/resources.rb +64 -0
- data/lib/twpipeline/rusage.rb +41 -0
- data/lib/twpipeline/segmenter.rb +134 -0
- data/lib/twpipeline/sorting.rb +51 -0
- data/lib/twpipeline/sources/hplt.rb +112 -0
- data/lib/twpipeline/stage.rb +71 -0
- data/lib/twpipeline/stages/count.rb +89 -0
- data/lib/twpipeline/stages/dedup.rb +46 -0
- data/lib/twpipeline/stages/evidence.rb +80 -0
- data/lib/twpipeline/stages/ingest.rb +66 -0
- data/lib/twpipeline/stages/lexicon.rb +16 -0
- data/lib/twpipeline/stages/normalize.rb +15 -0
- data/lib/twpipeline/stages/script.rb +14 -0
- data/lib/twpipeline/stages/segment.rb +50 -0
- data/lib/twpipeline/stages/tokenize.rb +42 -0
- data/lib/twpipeline/version.rb +5 -0
- data/lib/twpipeline.rb +59 -0
- data/sig/twpipeline.rbs +228 -0
- metadata +149 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module TWPipeline
|
|
6
|
+
module Sources
|
|
7
|
+
class HPLT
|
|
8
|
+
EXCLUDED_TLDS = %w[cn hk mo sg my].to_set.freeze
|
|
9
|
+
TAIWAN_TLD = "tw"
|
|
10
|
+
TAIWAN_LANG = /\Azh-(?:TW|Hant-TW)\b/i
|
|
11
|
+
URL_HEAD = 4096
|
|
12
|
+
URL = /"u":"([^"]*)"/n
|
|
13
|
+
HTML_LANG = /"html_lang":\[([^\]]*)\]/n
|
|
14
|
+
REGISTERS = %w[MT LY SP ID NA HI IN OP IP].freeze
|
|
15
|
+
|
|
16
|
+
Decision = Data.define(:keep, :gate, :host)
|
|
17
|
+
|
|
18
|
+
def initialize(allowlist: Set.new)
|
|
19
|
+
@allowlist = allowlist
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def each_record(io)
|
|
23
|
+
return to_enum(:each_record, io) unless block_given?
|
|
24
|
+
|
|
25
|
+
io.each_line do |line|
|
|
26
|
+
decision = decide(line)
|
|
27
|
+
next if decision.gate == :excluded
|
|
28
|
+
|
|
29
|
+
yield build(line, decision) if decision.keep || decision.gate == :residue
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def decide(line)
|
|
34
|
+
host = host_of(line)
|
|
35
|
+
return Decision.new(keep: false, gate: :excluded, host: host) if host.nil?
|
|
36
|
+
|
|
37
|
+
labels = host.split(".")
|
|
38
|
+
tld = labels.last
|
|
39
|
+
return Decision.new(keep: false, gate: :excluded, host: host) if EXCLUDED_TLDS.include?(tld)
|
|
40
|
+
return Decision.new(keep: true, gate: :tld, host: host) if tld == TAIWAN_TLD
|
|
41
|
+
return Decision.new(keep: true, gate: :allowlist, host: host) if @allowlist.include?(host)
|
|
42
|
+
return Decision.new(keep: true, gate: :html_lang, host: host) if taiwan_lang?(line)
|
|
43
|
+
|
|
44
|
+
Decision.new(keep: false, gate: :residue, host: host)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def host_of(line)
|
|
48
|
+
url = probe(line, URL_HEAD)[URL, 1] || line[URL, 1]
|
|
49
|
+
return nil if url.nil?
|
|
50
|
+
|
|
51
|
+
URI.parse(url).host&.downcase&.delete_prefix("www.")
|
|
52
|
+
rescue URI::InvalidURIError
|
|
53
|
+
url[%r{\Ahttps?://([^/:?#]+)}i, 1]&.downcase&.delete_prefix("www.")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def probe(line, size) = line.byteslice(0, size).force_encoding(Encoding::BINARY)
|
|
57
|
+
|
|
58
|
+
def taiwan_lang?(line)
|
|
59
|
+
captured = line[HTML_LANG, 1]
|
|
60
|
+
return false if captured.nil?
|
|
61
|
+
|
|
62
|
+
captured.scan(/"([^"]*)"/).flatten.any? { |tag| tag.match?(TAIWAN_LANG) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build(line, decision)
|
|
66
|
+
document = Jsonl.parse(line.dup.force_encoding(Encoding::UTF_8).scrub)
|
|
67
|
+
{
|
|
68
|
+
id: document[:id],
|
|
69
|
+
source: "hplt",
|
|
70
|
+
url: document[:u],
|
|
71
|
+
host: decision.host,
|
|
72
|
+
gate: decision.gate.to_s,
|
|
73
|
+
keep: decision.keep,
|
|
74
|
+
crawl: document[:crawl_id],
|
|
75
|
+
html_lang: document[:html_lang],
|
|
76
|
+
lang: Array(document[:lang]).first,
|
|
77
|
+
lang_prob: Array(document[:prob]).first,
|
|
78
|
+
doc_score: score(document),
|
|
79
|
+
register: register(document),
|
|
80
|
+
text: document[:text].to_s,
|
|
81
|
+
ok: true,
|
|
82
|
+
findings: []
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def score(document)
|
|
87
|
+
scores = Array(document[:doc_scores]).grep(Numeric)
|
|
88
|
+
scores.empty? ? nil : (scores.sum / scores.length.to_f).round(2)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def register(document)
|
|
92
|
+
labels = document[:"web-register"]
|
|
93
|
+
return nil unless labels.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
labels.max_by { |_, value| value.to_f }&.first&.to_s
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class << self
|
|
99
|
+
def open(path, &block)
|
|
100
|
+
command = path.to_s.end_with?(".zst") ? ["zstd", "-dc", "-T0", path.to_s] : ["cat", path.to_s]
|
|
101
|
+
IO.popen(command, "rb") { |io| block.call(io) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def allowlist(path)
|
|
105
|
+
return Set.new if path.nil? || !Pathname(path).exist?
|
|
106
|
+
|
|
107
|
+
Pathname(path).each_line.map { |line| line.strip.downcase }.reject(&:empty?).to_set
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TWPipeline
|
|
4
|
+
class Stage
|
|
5
|
+
REGISTRY = {}
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def register(slug, order)
|
|
9
|
+
@slug = slug
|
|
10
|
+
@order = order
|
|
11
|
+
REGISTRY[slug] = self
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def slug = @slug
|
|
15
|
+
|
|
16
|
+
def order = @order
|
|
17
|
+
|
|
18
|
+
def ordered = REGISTRY.values.sort_by(&:order)
|
|
19
|
+
|
|
20
|
+
def lookup(name)
|
|
21
|
+
REGISTRY[name] ||
|
|
22
|
+
REGISTRY.values.find { |stage| stage.slug.split("_", 2).last == name } ||
|
|
23
|
+
raise(Error, "unknown stage: #{name} (known: #{REGISTRY.keys.join(", ")})")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(policy: TWFilter::Policy.corpus, drop: false, resources: TWPipeline.resources, **options)
|
|
28
|
+
@policy = policy
|
|
29
|
+
@drop = drop
|
|
30
|
+
@resources = resources
|
|
31
|
+
@options = options
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :policy, :resources, :options
|
|
35
|
+
|
|
36
|
+
def drop? = @drop
|
|
37
|
+
|
|
38
|
+
def call(input, output) = raise NotImplementedError
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def reject(record, findings)
|
|
43
|
+
record.merge(ok: false, findings: record.fetch(:findings, []) + findings.map(&:to_h))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def flag(record, findings)
|
|
47
|
+
return record if findings.empty?
|
|
48
|
+
|
|
49
|
+
merged = record.fetch(:findings, []) + findings.map(&:to_h)
|
|
50
|
+
record.merge(ok: record.fetch(:ok, true) && findings.none?(&:reject?), findings: merged)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class MapStage < Stage
|
|
55
|
+
def call(input, output)
|
|
56
|
+
Parallel.pipe(input, output, workers: resources.cores) { |line| process(line) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def process(line)
|
|
60
|
+
stripped = line.strip
|
|
61
|
+
return nil if stripped.empty?
|
|
62
|
+
|
|
63
|
+
record = transform(Jsonl.parse(stripped))
|
|
64
|
+
return nil if record.nil? || (drop? && record[:ok] == false)
|
|
65
|
+
|
|
66
|
+
Jsonl.dump(record)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def transform(record) = raise NotImplementedError
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TWPipeline
|
|
4
|
+
module Stages
|
|
5
|
+
class Count < Stage
|
|
6
|
+
register("09_count", 9)
|
|
7
|
+
|
|
8
|
+
DEFAULT_ORDERS = [1, 2, 3, 4].freeze
|
|
9
|
+
|
|
10
|
+
def call(input, output)
|
|
11
|
+
sinks = {}
|
|
12
|
+
pipelines = orders.to_h { |order| [order, sink(order, sinks)] }
|
|
13
|
+
records = 0
|
|
14
|
+
|
|
15
|
+
Jsonl.each(input) do |record|
|
|
16
|
+
records += 1
|
|
17
|
+
units = units_of(record)
|
|
18
|
+
part = part_of(record)
|
|
19
|
+
orders.each do |order|
|
|
20
|
+
emit(units, order) { |gram| pipelines.fetch(order).call(part ? "#{gram}\t#{part}" : gram) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
results = close(sinks)
|
|
25
|
+
output.puts(Jsonl.dump({records: records, orders: results}))
|
|
26
|
+
{records: records, orders: results}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def orders = @orders ||= Array(options.fetch(:orders, DEFAULT_ORDERS)).map(&:to_i).sort
|
|
32
|
+
|
|
33
|
+
def floor = options.fetch(:floor, 1).to_i
|
|
34
|
+
|
|
35
|
+
def unit = options.fetch(:unit, "token")
|
|
36
|
+
|
|
37
|
+
def once_per = options[:once_per]&.to_s
|
|
38
|
+
|
|
39
|
+
def part_of(record)
|
|
40
|
+
return options[:parts]&.then { |field| record[field.to_sym] } if once_per.nil?
|
|
41
|
+
|
|
42
|
+
record[once_per.to_sym] || record[:doc] || record[:id]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def directory = Pathname(options.fetch(:out, TWPipeline.work.join("counts"))).tap(&:mkpath)
|
|
46
|
+
|
|
47
|
+
def units_of(record)
|
|
48
|
+
case unit
|
|
49
|
+
in "token"
|
|
50
|
+
Array(record[:tokens])
|
|
51
|
+
in "char"
|
|
52
|
+
record.fetch(:text, "").chars
|
|
53
|
+
in other
|
|
54
|
+
raise Error, "unknown unit: #{other}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def emit(units, order)
|
|
59
|
+
return if units.length < order
|
|
60
|
+
|
|
61
|
+
joiner = unit == "token" ? " " : ""
|
|
62
|
+
units.each_cons(order) { |gram| yield gram.join(joiner) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def sink(order, sinks)
|
|
66
|
+
path = directory.join("#{unit}_#{order}gram.tsv")
|
|
67
|
+
holder = {path: path, thread: nil, writer: nil}
|
|
68
|
+
reader, writer = IO.pipe
|
|
69
|
+
holder[:writer] = writer
|
|
70
|
+
holder[:thread] = Thread.new do
|
|
71
|
+
Sorting.tally(path, floor: floor, resources: resources, collapse: !once_per.nil?) do |push|
|
|
72
|
+
reader.each_line { |line| push.call(line.chomp) }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
sinks[order] = holder
|
|
76
|
+
|
|
77
|
+
-> (gram) { writer.puts(gram) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def close(sinks)
|
|
81
|
+
sinks.transform_values do |holder|
|
|
82
|
+
holder.fetch(:writer).close
|
|
83
|
+
result = holder.fetch(:thread).value
|
|
84
|
+
{path: holder.fetch(:path).to_s}.merge(result)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module TWPipeline
|
|
6
|
+
module Stages
|
|
7
|
+
class Dedup < Stage
|
|
8
|
+
register("07_dedup", 7)
|
|
9
|
+
|
|
10
|
+
MASK = (1 << 62) - 1
|
|
11
|
+
|
|
12
|
+
def call(input, output)
|
|
13
|
+
seen = Set.new
|
|
14
|
+
hosts = Hash.new { |memo, key| memo[key] = Set.new }
|
|
15
|
+
read = 0
|
|
16
|
+
written = 0
|
|
17
|
+
repeats = 0
|
|
18
|
+
|
|
19
|
+
Jsonl.each(input) do |record|
|
|
20
|
+
read += 1
|
|
21
|
+
key = fingerprint(record.fetch(:text, ""))
|
|
22
|
+
hosts[key] << record[:host] if record[:host] && hosts[key].size < ubiquity
|
|
23
|
+
|
|
24
|
+
if seen.add?(key)
|
|
25
|
+
written += 1
|
|
26
|
+
output.puts(Jsonl.dump(record))
|
|
27
|
+
else
|
|
28
|
+
repeats += 1
|
|
29
|
+
output.puts(Jsonl.dump(reject(record, [duplicate]))) unless drop?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
boilerplate = hosts.count { |_, group| group.size >= ubiquity }
|
|
34
|
+
{read: read, written: written, duplicates: repeats, types: seen.size, boilerplate_types: boilerplate}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def ubiquity = options.fetch(:ubiquity, 5).to_i
|
|
40
|
+
|
|
41
|
+
def duplicate = TWFilter::Finding.new(check: :dedup, code: :duplicate)
|
|
42
|
+
|
|
43
|
+
def fingerprint(text) = Digest::SHA256.digest(text).unpack1("Q>") & MASK
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TWPipeline
|
|
4
|
+
module Stages
|
|
5
|
+
class Evidence < Stage
|
|
6
|
+
register("06_evidence", 6)
|
|
7
|
+
|
|
8
|
+
BATCH = 20_000
|
|
9
|
+
|
|
10
|
+
def call(input, output)
|
|
11
|
+
blocks = 0
|
|
12
|
+
kept = 0
|
|
13
|
+
dropped = 0
|
|
14
|
+
batch = []
|
|
15
|
+
|
|
16
|
+
flush = lambda do
|
|
17
|
+
Parallel.map(batch, workers: resources.cores) { |records| judge(records) }.each do |verdict|
|
|
18
|
+
blocks += 1
|
|
19
|
+
verdict.each do |record|
|
|
20
|
+
record[:ok] == false ? dropped += 1 : kept += 1
|
|
21
|
+
output.puts(Jsonl.dump(record)) unless drop? && record[:ok] == false
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
batch = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
each_block(input) do |records|
|
|
29
|
+
batch << records
|
|
30
|
+
flush.call if batch.length >= BATCH
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
flush.call if batch.any?
|
|
34
|
+
|
|
35
|
+
{blocks: blocks, kept: kept, dropped: dropped}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def key(record) = options.fetch(:group, "doc").then { |field| record[field.to_sym] }
|
|
41
|
+
|
|
42
|
+
def size = options.fetch(:block, TWFilter::Block::SIZE).to_i
|
|
43
|
+
|
|
44
|
+
def each_block(input)
|
|
45
|
+
current = nil
|
|
46
|
+
buffer = []
|
|
47
|
+
|
|
48
|
+
Jsonl.each(input) do |record|
|
|
49
|
+
identity = key(record)
|
|
50
|
+
if (identity != current && !buffer.empty?) || buffer.length >= size
|
|
51
|
+
yield buffer
|
|
52
|
+
buffer = []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
current = identity
|
|
56
|
+
buffer << record
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
yield buffer unless buffer.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def judge(records)
|
|
63
|
+
rejected = records.count { |record| record[:ok] == false }
|
|
64
|
+
evidence = TWFilter::Evidence.count(records.map { |record| record.fetch(:text, "") }.join)
|
|
65
|
+
required = policy.evidence_per_100 * records.length / 100.0
|
|
66
|
+
contaminated = rejected > policy.block_tolerance * records.length
|
|
67
|
+
finding = block_finding(contaminated, evidence, required)
|
|
68
|
+
|
|
69
|
+
records.map { |record| finding ? flag(record, [finding]) : record.merge(evidence: evidence) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def block_finding(contaminated, evidence, required)
|
|
73
|
+
return TWFilter::Finding.new(check: :block, code: :contaminated_block) if contaminated
|
|
74
|
+
return nil if evidence >= required
|
|
75
|
+
|
|
76
|
+
TWFilter::Finding.new(check: :block, code: :no_taiwan_evidence, detail: format("%.2f", evidence))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TWPipeline
|
|
4
|
+
module Stages
|
|
5
|
+
class Ingest < Stage
|
|
6
|
+
register("01_ingest", 1)
|
|
7
|
+
|
|
8
|
+
def call(input, output)
|
|
9
|
+
kept = 0
|
|
10
|
+
residue = 0
|
|
11
|
+
gates = Hash.new(0)
|
|
12
|
+
sink = residue_path&.then { |path|
|
|
13
|
+
path.dirname.mkpath
|
|
14
|
+
path.open("w")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
per_host = Hash.new(0)
|
|
18
|
+
|
|
19
|
+
reader(input) do |io|
|
|
20
|
+
source.each_record(io) do |record|
|
|
21
|
+
gates[record[:gate]] += 1
|
|
22
|
+
next if capped?(per_host, record)
|
|
23
|
+
|
|
24
|
+
if record[:keep]
|
|
25
|
+
kept += 1
|
|
26
|
+
output.puts(Jsonl.dump(record.except(:keep)))
|
|
27
|
+
elsif sink
|
|
28
|
+
residue += 1
|
|
29
|
+
sink.puts(Jsonl.dump(record.except(:keep)))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sink&.close
|
|
35
|
+
{kept: kept, residue: residue, gates: gates}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def host_cap = options[:host_cap]&.to_i
|
|
41
|
+
|
|
42
|
+
def capped?(per_host, record)
|
|
43
|
+
return false if host_cap.nil? || record[:host].nil?
|
|
44
|
+
|
|
45
|
+
per_host[record[:host]] += 1
|
|
46
|
+
per_host[record[:host]] > host_cap
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def source
|
|
50
|
+
@source ||= case options.fetch(:source, "hplt")
|
|
51
|
+
in "hplt"
|
|
52
|
+
Sources::HPLT.new(allowlist: Sources::HPLT.allowlist(options[:allowlist]))
|
|
53
|
+
in unknown
|
|
54
|
+
raise Error, "unknown source: #{unknown}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def residue_path = options[:residue]&.then { |path| Pathname(path) }
|
|
59
|
+
|
|
60
|
+
def reader(input, &block)
|
|
61
|
+
path = options[:input]
|
|
62
|
+
path ? Sources::HPLT.open(path, &block) : block.call(input)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TWPipeline
|
|
4
|
+
module Stages
|
|
5
|
+
class Lexicon < MapStage
|
|
6
|
+
register("05_lexicon", 5)
|
|
7
|
+
|
|
8
|
+
CHECKS = [TWFilter::Checks::Lexicon, TWFilter::Checks::Erhua, TWFilter::Checks::Wenyan].freeze
|
|
9
|
+
|
|
10
|
+
def transform(record)
|
|
11
|
+
subject = TWFilter::Subject.new(record.fetch(:text, ""), policy: policy)
|
|
12
|
+
flag(record, CHECKS.flat_map { |check| check.call(subject) })
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TWPipeline
|
|
4
|
+
module Stages
|
|
5
|
+
class Normalize < MapStage
|
|
6
|
+
register("02_normalize", 2)
|
|
7
|
+
|
|
8
|
+
def transform(record)
|
|
9
|
+
original = record.fetch(:text, "")
|
|
10
|
+
normalized = TWFilter.normalize(original, collapse_repeats: options.fetch(:collapse_repeats, true))
|
|
11
|
+
record.merge(text: normalized, normalized: normalized != original)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TWPipeline
|
|
4
|
+
module Stages
|
|
5
|
+
class Script < MapStage
|
|
6
|
+
register("04_script", 4)
|
|
7
|
+
|
|
8
|
+
def transform(record)
|
|
9
|
+
subject = TWFilter::Subject.new(record.fetch(:text, ""), policy: policy)
|
|
10
|
+
flag(record.merge(tier: TWFilter::Checks::Script.tier_of(subject.text)), TWFilter::Checks::Script.call(subject))
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TWPipeline
|
|
4
|
+
module Stages
|
|
5
|
+
class Segment < Stage
|
|
6
|
+
register("03_segment", 3)
|
|
7
|
+
|
|
8
|
+
def call(input, output)
|
|
9
|
+
counts = Parallel.pipe(input, output, workers: resources.cores) do |line|
|
|
10
|
+
stripped = line.strip
|
|
11
|
+
next nil if stripped.empty?
|
|
12
|
+
|
|
13
|
+
split(Jsonl.parse(stripped)).map { |record| Jsonl.dump(record) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
{documents: counts.fetch(:read), sentences: counts.fetch(:written)}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def split(record)
|
|
22
|
+
TWFilter::Sentences
|
|
23
|
+
.split(record.fetch(:text, ""), clause: options.fetch(:clause, true))
|
|
24
|
+
.each_with_index
|
|
25
|
+
.filter_map { |text, index| sentence(record, text, index) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
CARRIED = %i[source host gate register].freeze
|
|
29
|
+
|
|
30
|
+
def sentence(record, text, index)
|
|
31
|
+
subject = TWFilter::Subject.new(text, policy: policy)
|
|
32
|
+
findings = TWFilter::Checks::Shape.call(subject)
|
|
33
|
+
return nil if drop? && findings.any?(&:reject?)
|
|
34
|
+
|
|
35
|
+
flag(
|
|
36
|
+
record.slice(*CARRIED).merge(
|
|
37
|
+
id: "#{record[:id]}##{index}",
|
|
38
|
+
doc: record[:id],
|
|
39
|
+
index: index,
|
|
40
|
+
han: subject.han,
|
|
41
|
+
text: text,
|
|
42
|
+
ok: true,
|
|
43
|
+
findings: []
|
|
44
|
+
),
|
|
45
|
+
findings
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TWPipeline
|
|
4
|
+
module Stages
|
|
5
|
+
class Tokenize < MapStage
|
|
6
|
+
register("08_tokenize", 8)
|
|
7
|
+
|
|
8
|
+
FIT_LINES = 200_000
|
|
9
|
+
|
|
10
|
+
def call(input, output)
|
|
11
|
+
head = []
|
|
12
|
+
lines = input.each_line
|
|
13
|
+
lines.each { |line|
|
|
14
|
+
head << line
|
|
15
|
+
break if head.length >= fit_lines
|
|
16
|
+
}
|
|
17
|
+
prime(head)
|
|
18
|
+
|
|
19
|
+
Parallel.pipe(head.each + lines, output, workers: resources.cores) { |line| process(line) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def transform(record) = record.merge(tokens: segmenter.call(record.fetch(:text, "")))
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def fit_lines = options.fetch(:fit_lines, FIT_LINES).to_i
|
|
27
|
+
|
|
28
|
+
def segmenter = @segmenter ||= Segmenter.load(vocabulary)
|
|
29
|
+
|
|
30
|
+
def vocabulary
|
|
31
|
+
options[:vocabulary] ||
|
|
32
|
+
ENV["TWP_VOCABULARY"] ||
|
|
33
|
+
raise(Error, "--vocabulary is required (JSON with \"words\" and \"chars\")")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def prime(head)
|
|
37
|
+
sample = head.filter_map { |line| Jsonl.parse(line.strip)[:text] unless line.strip.empty? }
|
|
38
|
+
segmenter.fit(Segmenter.runs(sample.join("\n")), rounds: options.fetch(:rounds, 2).to_i)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/twpipeline.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "set"
|
|
6
|
+
require "twfilter"
|
|
7
|
+
|
|
8
|
+
require_relative "twpipeline/version"
|
|
9
|
+
require_relative "twpipeline/resources"
|
|
10
|
+
require_relative "twpipeline/parallel"
|
|
11
|
+
require_relative "twpipeline/rusage"
|
|
12
|
+
require_relative "twpipeline/bench"
|
|
13
|
+
require_relative "twpipeline/jsonl"
|
|
14
|
+
require_relative "twpipeline/sorting"
|
|
15
|
+
require_relative "twpipeline/frequency"
|
|
16
|
+
require_relative "twpipeline/segmenter"
|
|
17
|
+
require_relative "twpipeline/stage"
|
|
18
|
+
require_relative "twpipeline/sources/hplt"
|
|
19
|
+
require_relative "twpipeline/stages/ingest"
|
|
20
|
+
require_relative "twpipeline/stages/normalize"
|
|
21
|
+
require_relative "twpipeline/stages/segment"
|
|
22
|
+
require_relative "twpipeline/stages/script"
|
|
23
|
+
require_relative "twpipeline/stages/lexicon"
|
|
24
|
+
require_relative "twpipeline/stages/evidence"
|
|
25
|
+
require_relative "twpipeline/stages/dedup"
|
|
26
|
+
require_relative "twpipeline/stages/tokenize"
|
|
27
|
+
require_relative "twpipeline/stages/count"
|
|
28
|
+
|
|
29
|
+
module TWPipeline
|
|
30
|
+
class Error < StandardError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
ROOT = Pathname(__dir__).parent
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
attr_writer :resources
|
|
37
|
+
|
|
38
|
+
def resources = @resources ||= Resources.detect
|
|
39
|
+
|
|
40
|
+
def work = Pathname(ENV.fetch("TWP_WORK", ROOT.parent.join("work").to_s))
|
|
41
|
+
|
|
42
|
+
def data(*parts) = ROOT.join("data", *parts)
|
|
43
|
+
|
|
44
|
+
def warm_up
|
|
45
|
+
TWFilter::Checks::Script.tiers
|
|
46
|
+
TWFilter::Checks::Lexicon.hard_terms
|
|
47
|
+
TWFilter::Checks::Lexicon.soft_terms
|
|
48
|
+
TWFilter::Checks::Lexicon.exceptions
|
|
49
|
+
TWFilter::Checks::Lexicon.regional_terms
|
|
50
|
+
TWFilter::Checks::Lexicon.foreign_topics
|
|
51
|
+
TWFilter::Checks::Lexicon.cantonese_chars
|
|
52
|
+
TWFilter::Checks::Erhua.headed
|
|
53
|
+
TWFilter::Checks::Erhua.tailed
|
|
54
|
+
TWFilter::Checks::Wenyan.markers
|
|
55
|
+
TWFilter::Evidence.terms
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|