Skip to content

Commit f6a3ee8

Browse files
committed
updates for earlier jdk
1 parent cbd1860 commit f6a3ee8

3 files changed

Lines changed: 135 additions & 130 deletions

File tree

tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/JunkDetector.java

Lines changed: 114 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,11 @@
5656
* <p>All features are calibrated (mu/sigma) on held-out dev text so their z-scores
5757
* are on a common scale.
5858
*
59-
* <ul>
60-
* <li><b>Version 1</b>: bigrams only; z-score = z1.</li>
61-
* <li><b>Version 2</b>: equal-weight average: {@code (z1 + z2 + z3) / 3}.</li>
62-
* <li><b>Version 3</b>: per-script learned linear combination:
63-
* {@code w1*z1 + w2*z2 + w3*z3 + bias}, where weights are fit by logistic
64-
* regression on clean vs. corrupted dev windows. The natural junk threshold
65-
* is 0 (positive logit = clean); use a negative threshold for conservative
66-
* detection (e.g., {@code score < -1}).</li>
67-
* </ul>
59+
* <p>Features are combined by a per-script logistic regression classifier:
60+
* {@code w1*z1 + w2*z2 + w3*z3 + w4*z4 + bias}, where weights are fit on
61+
* clean vs. corrupted dev windows. The natural junk threshold is 0 (positive
62+
* logit = clean); use a negative threshold for conservative detection
63+
* (e.g., {@code score &lt; -1}).</p>
6864
*
6965
* <p>Instances are immutable and thread-safe after construction.
7066
*
@@ -182,7 +178,7 @@ public static JunkDetector loadFromPath(Path path) throws IOException {
182178

183179
/**
184180
* Loads a model from an {@link InputStream}. Gzip-detection is automatic.
185-
* Supports model versions 1, 2, and 3.
181+
* Supports model versions 1 through 5.
186182
*/
187183
public static JunkDetector load(InputStream rawIs) throws IOException {
188184
byte[] peek = rawIs.readNBytes(2);
@@ -201,86 +197,62 @@ public static JunkDetector load(InputStream rawIs) throws IOException {
201197
throw new IOException("Not a JunkDetector model file (bad magic)");
202198
}
203199
int version = dis.readUnsignedByte();
204-
if (version < 1 || version > 4) {
205-
throw new IOException("Unsupported model version: " + version);
200+
if (version != 5) {
201+
throw new IOException("Unsupported model version: " + version
202+
+ ". Only version 5 is supported. Retrain the model with TrainJunkModel.");
206203
}
207204

208205
int numScripts = dis.readInt();
209206

210-
// Version 2+: read global block table dimension
211-
int blockN = 0;
212-
Map<Character.UnicodeBlock, Integer> blockIndex = null;
213-
if (version >= 2) {
214-
blockN = dis.readUnsignedShort();
215-
blockIndex = buildBlockIndex();
216-
int expectedN = blockIndex.size() + 1;
217-
if (blockN != expectedN) {
218-
throw new IOException(String.format(
219-
"Block table dimension mismatch: model has %d but JVM gives %d. "
220-
+ "Model was trained with a different Java version.", blockN, expectedN));
221-
}
207+
// Block names (v5): stored in model for JVM-independence
208+
int blockN = dis.readUnsignedShort();
209+
String[] blockNames = new String[blockN - 1];
210+
for (int i = 0; i < blockN - 1; i++) {
211+
int nameLen = dis.readUnsignedShort();
212+
blockNames[i] = new String(dis.readNBytes(nameLen), StandardCharsets.UTF_8);
222213
}
214+
Map<Character.UnicodeBlock, Integer> blockIndex = buildBlockIndexFromNames(blockNames);
223215

224-
Map<String, float[]> tables = new HashMap<>(numScripts * 2);
225-
Map<String, float[]> calibrations = new HashMap<>(numScripts * 2);
226-
227-
Map<String, float[]> blockTables = version >= 2 ? new HashMap<>(numScripts * 2) : null;
228-
Map<String, float[]> blockCalibrations = version >= 2 ? new HashMap<>(numScripts * 2) : null;
229-
Map<String, float[]> controlCalibrations = version >= 2 ? new HashMap<>(numScripts * 2) : null;
230-
Map<String, float[]> classifierWeights = version >= 3 ? new HashMap<>(numScripts * 2) : null;
231-
232-
// Version 4+: global script-transition section
233-
float[] scriptTransitionTable = null;
234-
float[] scriptTransitionCalibration = null;
235-
Map<String, Integer> scriptBucketIndex = null;
236-
int numScriptBuckets = 0;
237-
238-
if (version >= 4) {
239-
numScriptBuckets = dis.readUnsignedByte();
240-
scriptBucketIndex = new LinkedHashMap<>(numScriptBuckets * 2);
241-
for (int i = 0; i < numScriptBuckets; i++) {
242-
int nameLen = dis.readUnsignedShort();
243-
String bucketName = new String(dis.readNBytes(nameLen), StandardCharsets.UTF_8);
244-
scriptBucketIndex.put(bucketName, i);
245-
}
246-
scriptTransitionTable = readFloatTable(dis, numScriptBuckets * numScriptBuckets);
247-
float mu4 = dis.readFloat();
248-
float sigma4 = dis.readFloat();
249-
scriptTransitionCalibration = new float[]{mu4, sigma4};
216+
// Global script-transition section
217+
int numScriptBuckets = dis.readUnsignedByte();
218+
Map<String, Integer> scriptBucketIndex = new LinkedHashMap<>(numScriptBuckets * 2);
219+
for (int i = 0; i < numScriptBuckets; i++) {
220+
int nameLen = dis.readUnsignedShort();
221+
String bucketName = new String(dis.readNBytes(nameLen), StandardCharsets.UTF_8);
222+
scriptBucketIndex.put(bucketName, i);
250223
}
224+
float[] scriptTransitionTable = readFloatTable(dis, numScriptBuckets * numScriptBuckets);
225+
float[] scriptTransitionCalibration = new float[]{dis.readFloat(), dis.readFloat()};
226+
227+
Map<String, float[]> tables = new HashMap<>(numScripts * 2);
228+
Map<String, float[]> calibrations = new HashMap<>(numScripts * 2);
229+
Map<String, float[]> blockTables = new HashMap<>(numScripts * 2);
230+
Map<String, float[]> blockCalibrations = new HashMap<>(numScripts * 2);
231+
Map<String, float[]> controlCalibrations = new HashMap<>(numScripts * 2);
232+
Map<String, float[]> classifierWeights = new HashMap<>(numScripts * 2);
251233

252234
for (int s = 0; s < numScripts; s++) {
253235
int nameLen = dis.readUnsignedShort();
254236
String script = new String(dis.readNBytes(nameLen), StandardCharsets.UTF_8);
255237

256238
// Feature 1: byte bigrams
257-
float mu1 = dis.readFloat();
258-
float sigma1 = dis.readFloat();
259-
calibrations.put(script, new float[]{mu1, sigma1});
239+
calibrations.put(script, new float[]{dis.readFloat(), dis.readFloat()});
260240
tables.put(script, readFloatTable(dis, 65536));
261241

262-
if (version >= 2) {
263-
// Feature 2: named-block transitions
264-
float mu2 = dis.readFloat();
265-
float sigma2 = dis.readFloat();
266-
blockCalibrations.put(script, new float[]{mu2, sigma2});
267-
blockTables.put(script, readFloatTable(dis, blockN * blockN));
268-
269-
// Feature 3: control-byte fraction
270-
float mu3 = dis.readFloat();
271-
float sigma3 = dis.readFloat();
272-
controlCalibrations.put(script, new float[]{mu3, sigma3});
273-
274-
if (version >= 3) {
275-
// Classifier weights: num_features (1 byte) + num_features floats + 1 bias
276-
int numFeatures = dis.readUnsignedByte();
277-
float[] weights = new float[numFeatures + 1]; // last = bias
278-
for (int j = 0; j <= numFeatures; j++) {
279-
weights[j] = dis.readFloat();
280-
}
281-
classifierWeights.put(script, weights);
282-
}
242+
// Feature 2: named-block transitions
243+
blockCalibrations.put(script, new float[]{dis.readFloat(), dis.readFloat()});
244+
blockTables.put(script, readFloatTable(dis, blockN * blockN));
245+
246+
// Feature 3: control-byte fraction
247+
controlCalibrations.put(script, new float[]{dis.readFloat(), dis.readFloat()});
248+
249+
// Classifier weights: num_features (1 byte) + num_features floats + 1 bias
250+
int numFeatures = dis.readUnsignedByte();
251+
float[] weights = new float[numFeatures + 1]; // last = bias
252+
for (int j = 0; j <= numFeatures; j++) {
253+
weights[j] = dis.readFloat();
283254
}
255+
classifierWeights.put(script, weights);
284256
}
285257

286258
return new JunkDetector(version, tables, calibrations,
@@ -302,6 +274,7 @@ private static float[] readFloatTable(DataInputStream dis, int size) throws IOEx
302274
/**
303275
* Builds the stable ordered mapping from {@link Character.UnicodeBlock} to index.
304276
* This must produce the same ordering as {@link TrainJunkModel#buildBlockIndex()}.
277+
* Used for v2/v3/v4 models only; v5+ models store block names in the file.
305278
*/
306279
static Map<Character.UnicodeBlock, Integer> buildBlockIndex() {
307280
LinkedHashMap<Character.UnicodeBlock, Integer> index = new LinkedHashMap<>();
@@ -312,6 +285,31 @@ static Map<Character.UnicodeBlock, Integer> buildBlockIndex() {
312285
return Collections.unmodifiableMap(index);
313286
}
314287

288+
/**
289+
* Builds a block index from an ordered array of block names stored in a v5+ model.
290+
* Resolves each name via {@link Character.UnicodeBlock#forName(String)}.
291+
* Throws {@link IOException} if any name is not recognised by the current JVM —
292+
* this means the model was trained on a newer JVM; retrain on the minimum
293+
* supported JVM (Java 17) to produce a compatible model.
294+
*
295+
* @param blockNames ordered array of block names (index = position in block table)
296+
* @return unmodifiable map from UnicodeBlock to table index
297+
*/
298+
static Map<Character.UnicodeBlock, Integer> buildBlockIndexFromNames(String[] blockNames)
299+
throws IOException {
300+
Map<Character.UnicodeBlock, Integer> index = new HashMap<>(blockNames.length * 2);
301+
for (int i = 0; i < blockNames.length; i++) {
302+
try {
303+
Character.UnicodeBlock b = Character.UnicodeBlock.forName(blockNames[i]);
304+
index.put(b, i);
305+
} catch (IllegalArgumentException e) {
306+
throw new IOException("Unicode block not known to this JVM: " + blockNames[i]
307+
+ ". Model was trained on a newer JVM; retrain on Java 17.", e);
308+
}
309+
}
310+
return Collections.unmodifiableMap(index);
311+
}
312+
315313
// -----------------------------------------------------------------------
316314
// TextQualityDetector implementation
317315
// -----------------------------------------------------------------------
@@ -450,60 +448,53 @@ private float scoreChunk(byte[] utf8, String text, String script, float z4) {
450448
float[] cal1 = calibrations.get(script);
451449
float z1 = (meanBigramLogProb - cal1[0]) / cal1[1];
452450

453-
float z2 = 0f, z3 = 0f;
454-
if (modelVersion >= 2 && blockTables != null) {
455-
// Feature 2: named-block transition mean log-prob
456-
float[] blockTable = blockTables.get(script);
457-
if (blockTable != null) {
458-
int nullId = blockN - 1;
459-
int prev = -1;
460-
double blockSum = 0;
461-
int blockCount = 0;
462-
for (int i = 0; i < text.length(); ) {
463-
int cp = text.codePointAt(i);
464-
Character.UnicodeBlock b = Character.UnicodeBlock.of(cp);
465-
int blockId = b != null ? blockIndex.getOrDefault(b, nullId) : nullId;
466-
if (prev >= 0) {
467-
blockSum += blockTable[prev * blockN + blockId];
468-
blockCount++;
469-
}
470-
prev = blockId;
471-
i += Character.charCount(cp);
472-
}
473-
if (blockCount > 0) {
474-
float meanBlockLogProb = (float) (blockSum / blockCount);
475-
float[] cal2 = blockCalibrations.get(script);
476-
z2 = cal2 != null ? (meanBlockLogProb - cal2[0]) / cal2[1] : 0f;
451+
// Feature 2: named-block transition mean log-prob
452+
float z2 = 0f;
453+
float[] blockTable = blockTables.get(script);
454+
if (blockTable != null) {
455+
int nullId = blockN - 1;
456+
int prev = -1;
457+
double blockSum = 0;
458+
int blockCount = 0;
459+
for (int i = 0; i < text.length(); ) {
460+
int cp = text.codePointAt(i);
461+
Character.UnicodeBlock b = Character.UnicodeBlock.of(cp);
462+
int blockId = b != null ? blockIndex.getOrDefault(b, nullId) : nullId;
463+
if (prev >= 0) {
464+
blockSum += blockTable[prev * blockN + blockId];
465+
blockCount++;
477466
}
467+
prev = blockId;
468+
i += Character.charCount(cp);
478469
}
479-
480-
// Feature 3: control-byte fraction (stored as −fraction, so higher = cleaner)
481-
long controlCount = 0;
482-
for (byte b : utf8) {
483-
if (isControlByte(b & 0xFF)) controlCount++;
470+
if (blockCount > 0) {
471+
float meanBlockLogProb = (float) (blockSum / blockCount);
472+
float[] cal2 = blockCalibrations.get(script);
473+
z2 = cal2 != null ? (meanBlockLogProb - cal2[0]) / cal2[1] : 0f;
484474
}
485-
float controlScore = -(float) controlCount / utf8.length;
486-
float[] cal3 = controlCalibrations.get(script);
487-
z3 = cal3 != null ? (controlScore - cal3[0]) / cal3[1] : 0f;
488475
}
489476

490-
if (modelVersion >= 3 && classifierWeights != null) {
491-
float[] cw = classifierWeights.get(script);
492-
if (cw != null) {
493-
int nFeat = cw.length - 1; // bias is last
494-
float logit = cw[nFeat]; // bias
495-
if (nFeat >= 1) logit += cw[0] * z1;
496-
if (nFeat >= 2) logit += cw[1] * z2;
497-
if (nFeat >= 3) logit += cw[2] * z3;
498-
if (nFeat >= 4) logit += cw[3] * z4;
499-
return logit;
500-
}
501-
return (z1 + z2 + z3) / 4.0f; // fallback: equal weight including z4
502-
} else if (modelVersion >= 2 && blockTables != null) {
503-
return (z1 + z2 + z3) / 3.0f;
504-
} else {
505-
return z1;
477+
// Feature 3: control-byte fraction (stored as −fraction, so higher = cleaner)
478+
long controlCount = 0;
479+
for (byte b : utf8) {
480+
if (isControlByte(b & 0xFF)) controlCount++;
481+
}
482+
float controlScore = -(float) controlCount / utf8.length;
483+
float[] cal3 = controlCalibrations.get(script);
484+
float z3 = cal3 != null ? (controlScore - cal3[0]) / cal3[1] : 0f;
485+
486+
// Per-script linear classifier: w1*z1 + w2*z2 + w3*z3 + w4*z4 + bias
487+
float[] cw = classifierWeights.get(script);
488+
if (cw != null) {
489+
int nFeat = cw.length - 1; // bias is last
490+
float logit = cw[nFeat]; // bias
491+
if (nFeat >= 1) logit += cw[0] * z1;
492+
if (nFeat >= 2) logit += cw[1] * z2;
493+
if (nFeat >= 3) logit += cw[2] * z3;
494+
if (nFeat >= 4) logit += cw[3] * z4;
495+
return logit;
506496
}
497+
return (z1 + z2 + z3 + z4) / 4.0f; // fallback: equal weight
507498
}
508499

509500
/**
@@ -512,8 +503,7 @@ private float scoreChunk(byte[] utf8, String text, String script, float z4) {
512503
* so that HIRAGANA, KATAKANA, and HAN remain distinct, preserving the
513504
* characteristic script-mixing pattern of Japanese text.
514505
*
515-
* <p>Returns 0 if no v4 model is loaded or the string has fewer than two
516-
* non-neutral codepoints.
506+
* <p>Returns 0 if the string has fewer than two non-neutral codepoints.
517507
*/
518508
private float computeScriptTransitionZ(String text) {
519509
if (scriptTransitionTable == null || scriptBucketIndex == null

tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/tools/TrainJunkModel.java

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,18 @@
7777
* The natural threshold is 0 (probability 0.5); use a negative threshold for
7878
* more conservative junk detection.
7979
*
80-
* <p>Output format: {@code JUNKDET1} gzipped binary, <b>version 4</b>.
81-
* Version 1 (bigrams only), version 2 (equal-weight average), and version 3 files can
82-
* still be loaded by {@code JunkDetector}.
80+
* <p>Output format: {@code JUNKDET1} gzipped binary, <b>version 5</b>.
81+
* Version 1–4 files can still be loaded by {@code JunkDetector} on the JVM they were trained on.
8382
*
8483
* <pre>
8584
* [8 bytes] magic "JUNKDET1" (ASCII)
8685
* [1 byte] version = 4
8786
* [4 bytes] num_scripts (big-endian int)
8887
* [2 bytes] block_N — number of distinct named Unicode blocks + 1 (unassigned)
88+
* // Block names section (version 5+): block_N-1 entries for JVM-independence
89+
* for i in [0, block_N-1):
90+
* [2 bytes] name length (big-endian ushort)
91+
* [name bytes] Unicode block name (Character.UnicodeBlock.toString())
8992
* // Global script-transition section (version 4+)
9093
* [1 byte] num_script_buckets
9194
* for each bucket:
@@ -121,7 +124,7 @@
121124
public class TrainJunkModel {
122125

123126
static final String MAGIC = "JUNKDET1";
124-
static final byte VERSION = 4;
127+
static final byte VERSION = 5;
125128

126129
/** Number of clean (and corrupted) windows used to train the per-script classifier. */
127130
static final int NUM_CLASSIFIER_SAMPLES = 500;
@@ -191,7 +194,7 @@ public static void main(String[] args) throws IOException {
191194
}
192195
}
193196

194-
System.out.println("=== TrainJunkModel (v4) ===");
197+
System.out.println("=== TrainJunkModel (v5) ===");
195198
System.out.println(" data-dir: " + dataDir);
196199
System.out.println(" output: " + output);
197200

@@ -359,7 +362,7 @@ public static void main(String[] args) throws IOException {
359362
saveModel(bigramTables, bigramCalibrations,
360363
blockTables, blockCalibrations,
361364
controlCalibrations, classifierWeights,
362-
blockN, scriptBuckets, scriptTransTable, scriptTransCal, output);
365+
blockIndex, blockN, scriptBuckets, scriptTransTable, scriptTransCal, output);
363366
System.out.printf("Model size: %,d bytes (%.1f MB)%n",
364367
Files.size(output), Files.size(output) / 1_000_000.0);
365368
System.out.println("Done.");
@@ -915,6 +918,7 @@ static void saveModel(TreeMap<String, float[]> bigramTables,
915918
TreeMap<String, float[]> blockCalibrations,
916919
TreeMap<String, float[]> controlCalibrations,
917920
TreeMap<String, float[]> classifierWeights,
921+
Map<Character.UnicodeBlock, Integer> blockIndex,
918922
int blockN,
919923
List<String> scriptBuckets,
920924
float[] scriptTransTable,
@@ -928,6 +932,17 @@ static void saveModel(TreeMap<String, float[]> bigramTables,
928932
dos.writeInt(bigramTables.size());
929933
dos.writeShort(blockN);
930934

935+
// Block names section (v5+): write ordered block names for JVM-independence
936+
String[] blockNames = new String[blockN - 1];
937+
for (Map.Entry<Character.UnicodeBlock, Integer> e : blockIndex.entrySet()) {
938+
blockNames[e.getValue()] = e.getKey().toString();
939+
}
940+
for (String name : blockNames) {
941+
byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8);
942+
dos.writeShort(nameBytes.length);
943+
dos.write(nameBytes);
944+
}
945+
931946
// Global script-transition section (v4+)
932947
int numBuckets = scriptBuckets.size();
933948
dos.writeByte(numBuckets);
Binary file not shown.

0 commit comments

Comments
 (0)