decompound 0.2.0 → 0.3.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 +4 -4
- data/.ruby-version +1 -0
- data/CHANGELOG.md +4 -0
- data/README.md +19 -23
- data/data/model.bin +0 -0
- data/lib/decompound/model.rb +79 -0
- data/lib/decompound/packer.rb +45 -0
- data/lib/decompound/version.rb +1 -1
- data/lib/decompound.rb +12 -11
- metadata +6 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cfbd2354f4af5ae69ac9e258b52e658a4ad859b7882d5c60150902dfc1a68480
|
|
4
|
+
data.tar.gz: 27ff07f69553d8467c004967f584f2316c35dada2d738cd7cc6a6edfb269ca0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 327855e9992b2e955de67bd03147547ed113c30a60c30e7b0bc819b14224de8f39f660847bf317e491cb0d3c2720dd23efac2a9b142a1a011a2109831d875233
|
|
7
|
+
data.tar.gz: ca70c7dd58def1472d87882b3838099e7ae6ce9b8e85f37dd5278d85fad5c7184925eeda8353bb65c38330f81fafaa60d65415cfa1f6b339f1eda200f345563e
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
4.0.6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-07-16
|
|
4
|
+
|
|
5
|
+
- Use binary model format: Reduce memory by searching a packed binary model instead of parsing JSON into hashes. Slower throughput though.
|
|
6
|
+
|
|
3
7
|
## [0.2.0] - 2026-07-15
|
|
4
8
|
|
|
5
9
|
- Train bundled model from German Wiktionary nouns (~95% head-detection accuracy on GermaNet compounds)
|
data/README.md
CHANGED
|
@@ -17,48 +17,36 @@ The bundled model is trained on 127,106 German Wiktionary noun lemmas and their
|
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
19
19
|
|
|
20
|
-
Install the gem and add to the application's Gemfile by executing:
|
|
21
|
-
|
|
22
20
|
```bash
|
|
23
21
|
bundle add decompound
|
|
24
22
|
```
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
Or without bundler:
|
|
27
25
|
|
|
28
26
|
```bash
|
|
29
27
|
gem install decompound
|
|
30
28
|
```
|
|
31
29
|
|
|
32
|
-
## Usage
|
|
33
|
-
|
|
34
|
-
```rb
|
|
35
|
-
require "decompound"
|
|
36
|
-
|
|
37
|
-
word = "Bodenbelag"
|
|
38
|
-
parts = Decompound.split(word)
|
|
39
|
-
# => ["boden", "belag"]
|
|
40
|
-
```
|
|
41
|
-
|
|
42
30
|
## Roadmap
|
|
43
31
|
|
|
44
|
-
-
|
|
45
|
-
-
|
|
32
|
+
- Provide different models (small, medium, large) (?)
|
|
33
|
+
- Recursive decompounding
|
|
34
|
+
- `Fussbodenbelag` → `Fuss` + `Bodenbelag` → `Fuss` + `Boden` + `Belag`
|
|
46
35
|
|
|
47
36
|
## Development
|
|
48
37
|
|
|
49
|
-
After checking out the repo, run `bin/setup` to install dependencies
|
|
50
|
-
|
|
51
|
-
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
38
|
+
After checking out the repo, run `bin/setup` to install dependencies, `rake test` to run the tests, and `bin/console` for an interactive prompt.
|
|
52
39
|
|
|
53
40
|
### Release process
|
|
54
41
|
|
|
55
42
|
For each release:
|
|
56
43
|
|
|
57
44
|
1. Update `lib/decompound/version.rb`
|
|
58
|
-
2.
|
|
59
|
-
3.
|
|
60
|
-
4.
|
|
61
|
-
5.
|
|
45
|
+
2. Run `bundle install` to update `Gemfile.lock`
|
|
46
|
+
3. Update `CHANGELOG.md`
|
|
47
|
+
4. Run tests and RBS validation
|
|
48
|
+
5. Commit with message `Prepare x.x.x release` and push
|
|
49
|
+
6. Run `bundle exec rake release`
|
|
62
50
|
|
|
63
51
|
### Training CharSplit probabilities
|
|
64
52
|
|
|
@@ -73,11 +61,19 @@ ruby bin/train.rb \
|
|
|
73
61
|
|
|
74
62
|
`--forms lemmas` uses only lemmas, while `--forms nominative` uses lemmas plus nominative inflections. Inflected forms provide the linking-element evidence (e.g. the *-s* in *Einkaufsliste*), so the bundled model uses `all`. The generated JSON contains CharSplit-compatible `prefix`, `infix`, and `suffix` probabilities.
|
|
75
63
|
|
|
64
|
+
### Packing the runtime model
|
|
65
|
+
|
|
66
|
+
The gem ships the probabilities as a binary table (`data/model.bin`), searched in place:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
ruby bin/pack.rb data/ngram_probs.json data/model.bin
|
|
70
|
+
```
|
|
71
|
+
|
|
76
72
|
## Provenance and credits
|
|
77
73
|
|
|
78
74
|
The noun corpus used to train the bundled probabilities is derived from [German Wiktionary](https://de.wiktionary.org/). It is extracted by [Wiktextract](https://github.com/tatuylonen/wiktextract) and distributed by [Kaikki.org](https://kaikki.org/). Wiktionary textual content is licensed under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) and, where applicable, the GNU Free Documentation License. See [`data/vendor/german-nouns/SOURCE.md`](data/vendor/german-nouns/SOURCE.md) for the pinned source, extraction process, and reproducibility details.
|
|
79
75
|
|
|
80
|
-
The splitting and training algorithms are Ruby ports of [CharSplit](https://github.com/dtuggener/CharSplit), created by Don Tuggener and described in
|
|
76
|
+
The splitting and training algorithms are Ruby ports of [CharSplit](https://github.com/dtuggener/CharSplit), created by Don Tuggener and described in [Incremental Coreference Resolution for German](https://doi.org/10.5167/uzh-124915) (University of Zurich, 2016). CharSplit is MIT licensed; its copyright and license are reproduced in [`LICENSE-CharSplit.txt`](LICENSE-CharSplit.txt).
|
|
81
77
|
|
|
82
78
|
See [`NOTICE.md`](NOTICE.md) for consolidated third-party notices.
|
|
83
79
|
|
data/data/model.bin
ADDED
|
Binary file
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Decompound
|
|
4
|
+
# Reads the binary model format (decompound-binary-v1):
|
|
5
|
+
#
|
|
6
|
+
# magic "DCPDv1\n"
|
|
7
|
+
# count uint32 LE
|
|
8
|
+
# bucket_count uint32 LE
|
|
9
|
+
# buckets bucket_count x (key prefix, NUL-padded to 3 bytes + uint32 LE first index)
|
|
10
|
+
# offsets (count + 1) x uint32 LE, byte offsets into the keys blob
|
|
11
|
+
# probabilities count x 3 bytes (prefix, infix, suffix), 255 = missing
|
|
12
|
+
# keys concatenated UTF-8 n-grams, sorted bytewise
|
|
13
|
+
#
|
|
14
|
+
# The file is held as a single string and searched in place, so loaded
|
|
15
|
+
# memory stays near file size instead of materializing millions of Ruby
|
|
16
|
+
# strings and hash entries. The buckets narrow each binary search to the
|
|
17
|
+
# keys sharing the query's first three bytes.
|
|
18
|
+
|
|
19
|
+
class Model
|
|
20
|
+
MAGIC = "DCPDv1\n"
|
|
21
|
+
PREFIX = 0
|
|
22
|
+
INFIX = 1
|
|
23
|
+
SUFFIX = 2
|
|
24
|
+
MISSING = 255
|
|
25
|
+
SCALE = 254.0
|
|
26
|
+
|
|
27
|
+
def self.load(path)
|
|
28
|
+
new(File.binread(path))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(data)
|
|
32
|
+
raise ArgumentError, "not a decompound binary model" unless data.byteslice(0, MAGIC.bytesize) == MAGIC
|
|
33
|
+
|
|
34
|
+
@data = data.freeze
|
|
35
|
+
@count, bucket_count = data.byteslice(MAGIC.bytesize, 8).unpack("VV")
|
|
36
|
+
buckets_at = MAGIC.bytesize + 8
|
|
37
|
+
@offsets_at = buckets_at + (bucket_count * 7)
|
|
38
|
+
@probabilities_at = @offsets_at + ((@count + 1) * 4)
|
|
39
|
+
@keys_at = @probabilities_at + (@count * 3)
|
|
40
|
+
@buckets = read_buckets(data, buckets_at, bucket_count)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def probability(ngram, position, default)
|
|
44
|
+
index = find(ngram.b)
|
|
45
|
+
return default unless index
|
|
46
|
+
|
|
47
|
+
code = @data.getbyte(@probabilities_at + (index * 3) + position)
|
|
48
|
+
(code == MISSING) ? default : code / SCALE
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def read_buckets(data, buckets_at, bucket_count)
|
|
54
|
+
firsts = bucket_count.times.map { |i| data.byteslice(buckets_at + (i * 7) + 3, 4).unpack1("V") }
|
|
55
|
+
firsts << @count
|
|
56
|
+
buckets = {}
|
|
57
|
+
bucket_count.times do |i|
|
|
58
|
+
buckets[data.byteslice(buckets_at + (i * 7), 3)] = [firsts[i], firsts[i + 1] - 1]
|
|
59
|
+
end
|
|
60
|
+
buckets.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def find(query)
|
|
64
|
+
low, high = @buckets[query.byteslice(0, 3).ljust(3, "\0")]
|
|
65
|
+
return nil unless low
|
|
66
|
+
|
|
67
|
+
while low <= high
|
|
68
|
+
mid = (low + high) / 2
|
|
69
|
+
from, upto = @data.byteslice(@offsets_at + (mid * 4), 8).unpack("VV")
|
|
70
|
+
case query <=> @data.byteslice(@keys_at + from, upto - from)
|
|
71
|
+
when 0 then return mid
|
|
72
|
+
when -1 then high = mid - 1
|
|
73
|
+
else low = mid + 1
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "model"
|
|
4
|
+
|
|
5
|
+
module Decompound
|
|
6
|
+
# Compiles the trained JSON probability tables into the binary runtime model, see Decompound::Model
|
|
7
|
+
|
|
8
|
+
class Packer
|
|
9
|
+
POSITIONS = {"prefix" => Model::PREFIX, "infix" => Model::INFIX, "suffix" => Model::SUFFIX}.freeze
|
|
10
|
+
|
|
11
|
+
def pack(probabilities)
|
|
12
|
+
entries = Hash.new { |hash, key| hash[key] = [Model::MISSING] * 3 }
|
|
13
|
+
POSITIONS.each do |position, slot|
|
|
14
|
+
probabilities.fetch(position).each do |ngram, probability|
|
|
15
|
+
entries[ngram.b][slot] = (probability.clamp(0.0, 1.0) * Model::SCALE).round
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
keys = entries.keys.sort!
|
|
20
|
+
offsets = []
|
|
21
|
+
blob = +""
|
|
22
|
+
buckets = +""
|
|
23
|
+
bucket_count = 0
|
|
24
|
+
previous_prefix = nil
|
|
25
|
+
keys.each_with_index do |key, index|
|
|
26
|
+
prefix = key.byteslice(0, 3).ljust(3, "\0")
|
|
27
|
+
if prefix != previous_prefix
|
|
28
|
+
buckets << prefix << [index].pack("V")
|
|
29
|
+
bucket_count += 1
|
|
30
|
+
previous_prefix = prefix
|
|
31
|
+
end
|
|
32
|
+
offsets << blob.bytesize
|
|
33
|
+
blob << key
|
|
34
|
+
end
|
|
35
|
+
offsets << blob.bytesize
|
|
36
|
+
|
|
37
|
+
Model::MAGIC.b +
|
|
38
|
+
[keys.length, bucket_count].pack("VV") +
|
|
39
|
+
buckets +
|
|
40
|
+
offsets.pack("V*") +
|
|
41
|
+
keys.flat_map { |key| entries.fetch(key) }.pack("C*") +
|
|
42
|
+
blob
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/decompound/version.rb
CHANGED
data/lib/decompound.rb
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
3
|
require_relative "decompound/version"
|
|
4
|
+
require_relative "decompound/model"
|
|
5
5
|
|
|
6
6
|
module Decompound
|
|
7
|
-
|
|
7
|
+
MODEL_PATH = File.expand_path("../data/model.bin", __dir__)
|
|
8
8
|
FUGEN_S_ENDINGS = %w[ts gs ks hls ns].freeze
|
|
9
9
|
|
|
10
10
|
def self.split(word)
|
|
@@ -20,7 +20,7 @@ module Decompound
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def self.split_candidates(word)
|
|
23
|
-
|
|
23
|
+
model = self.model
|
|
24
24
|
|
|
25
25
|
(3...(word.length - 2)).map do |position|
|
|
26
26
|
left = word[0...position]
|
|
@@ -28,11 +28,12 @@ module Decompound
|
|
|
28
28
|
left_ngram = without_fugen_s(left)
|
|
29
29
|
right_ngram = without_fugen_s(right)
|
|
30
30
|
|
|
31
|
-
suffix_probability =
|
|
32
|
-
prefix_probability =
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
suffix_probability = model.probability(left_ngram, Model::SUFFIX, -1)
|
|
32
|
+
prefix_probability = model.probability(right_ngram, Model::PREFIX, -1)
|
|
33
|
+
# CharSplit scans lengths up to word.length + 1; slices past the end of
|
|
34
|
+
# +right+ all yield +right+ itself, so stopping there is equivalent.
|
|
35
|
+
infix_probability = (3..right.length).map do |length|
|
|
36
|
+
model.probability(right[0, length], Model::INFIX, 1)
|
|
36
37
|
end.min
|
|
37
38
|
|
|
38
39
|
[prefix_probability - infix_probability + suffix_probability, left, right]
|
|
@@ -48,8 +49,8 @@ module Decompound
|
|
|
48
49
|
end
|
|
49
50
|
private_class_method :without_fugen_s
|
|
50
51
|
|
|
51
|
-
def self.
|
|
52
|
-
@
|
|
52
|
+
def self.model
|
|
53
|
+
@model ||= Model.load(MODEL_PATH)
|
|
53
54
|
end
|
|
54
|
-
private_class_method :
|
|
55
|
+
private_class_method :model
|
|
55
56
|
end
|
metadata
CHANGED
|
@@ -1,34 +1,21 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: decompound
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Camillo Visini
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: json
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - ">="
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: '2.9'
|
|
19
|
-
type: :runtime
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - ">="
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: '2.9'
|
|
11
|
+
dependencies: []
|
|
26
12
|
description: A Ruby gem for splitting German compound words into their constituent
|
|
27
13
|
parts
|
|
28
14
|
executables: []
|
|
29
15
|
extensions: []
|
|
30
16
|
extra_rdoc_files: []
|
|
31
17
|
files:
|
|
18
|
+
- ".ruby-version"
|
|
32
19
|
- CHANGELOG.md
|
|
33
20
|
- Justfile
|
|
34
21
|
- LICENSE-CharSplit.txt
|
|
@@ -36,11 +23,14 @@ files:
|
|
|
36
23
|
- NOTICE.md
|
|
37
24
|
- README.md
|
|
38
25
|
- Rakefile
|
|
26
|
+
- data/model.bin
|
|
39
27
|
- data/ngram_probs.json
|
|
40
28
|
- lib/charsplit/german_nouns.rb
|
|
41
29
|
- lib/charsplit/ngram_counter.rb
|
|
42
30
|
- lib/charsplit/trainer.rb
|
|
43
31
|
- lib/decompound.rb
|
|
32
|
+
- lib/decompound/model.rb
|
|
33
|
+
- lib/decompound/packer.rb
|
|
44
34
|
- lib/decompound/version.rb
|
|
45
35
|
- script/download_german_nouns.rb
|
|
46
36
|
- sig/decompound.rbs
|