这里会显示出您选择的修订版和当前版本之间的差别。
| 两侧同时换到之前的修订记录 前一修订版 后一修订版 | 前一修订版 | ||
|
zh:courses:textmining2026:ch03 [2026/09/14 14:41] pzczxs [UTF-8文件工具类] |
zh:courses:textmining2026:ch03 [2026/09/28 17:25] (当前版本) pzczxs [UTF-8文件工具类] |
||
|---|---|---|---|
| 行 1: | 行 1: | ||
| - | ====== 第三章 文本表示 ====== | + | ====== 第三章:文本表示 ====== |
| ===== 课件 ===== | ===== 课件 ===== | ||
| - | 下载:文本表示 | + | 下载:{{ :zh:courses:textmining2026:ch03.pptx |文本表示}} |
| - | ===== 第三方库依赖 ===== | ||
| ===== UTF-8文件工具类 ===== | ===== UTF-8文件工具类 ===== | ||
| <file java FileUtil.java> | <file java FileUtil.java> | ||
| 行 82: | 行 82: | ||
| } | } | ||
| </file> | </file> | ||
| - | |||
| ===== 基于Lucene的TF-IDF计算 ===== | ===== 基于Lucene的TF-IDF计算 ===== | ||
| + | ==== 第三方库依赖(POM.xml) ==== | ||
| + | <file xml> | ||
| + | <dependency> | ||
| + | <groupId>org.apache.lucene</groupId> | ||
| + | <artifactId>lucene-core</artifactId> | ||
| + | <version>8.11.2</version> | ||
| + | </dependency> | ||
| + | <dependency> | ||
| + | <groupId>org.apache.lucene</groupId> | ||
| + | <artifactId>lucene-analyzers-common</artifactId> | ||
| + | <version>8.11.2</version> | ||
| + | </dependency> | ||
| + | </file> | ||
| + | ==== 源代码 ==== | ||
| <file java LuceneTfIdfDemo.java> | <file java LuceneTfIdfDemo.java> | ||
| package cn.edu.bjut.textmining.chapter3; | package cn.edu.bjut.textmining.chapter3; | ||
| 行 157: | 行 170: | ||
| } | } | ||
| </file> | </file> | ||
| + | |||
| + | ===== 训练word2Vec模型 ===== | ||
| + | ==== 第三方库依赖(POM.xml) ==== | ||
| + | <file xml> | ||
| + | <dependency> | ||
| + | <groupId>org.deeplearning4j</groupId> | ||
| + | <artifactId>deeplearning4j-core</artifactId> | ||
| + | <version>1.0.0-M2.1</version> | ||
| + | </dependency> | ||
| + | <dependency> | ||
| + | <groupId>org.deeplearning4j</groupId> | ||
| + | <artifactId>deeplearning4j-nlp</artifactId> | ||
| + | <version>1.0.0-M2.1</version> | ||
| + | </dependency> | ||
| + | <!-- 必须指定后端,这里使用CPU版本,避免CUDA环境配置的麻烦 --> | ||
| + | <dependency> | ||
| + | <groupId>org.nd4j</groupId> | ||
| + | <artifactId>nd4j-native-platform</artifactId> | ||
| + | <version>1.0.0-M2.1</version> | ||
| + | </dependency> | ||
| + | </file> | ||
| + | ==== 源代码 ==== | ||
| + | <file java WordEmbeddingDemo.java> | ||
| + | package cn.edu.bjut.textmining.chapter3; | ||
| + | |||
| + | import cn.edu.bjut.textmining.util.FileUtil; | ||
| + | import cn.edu.bjut.textmining.util.WordListUtil; | ||
| + | |||
| + | import org.deeplearning4j.models.embeddings.learning.impl.elements.CBOW; | ||
| + | import org.deeplearning4j.models.word2vec.VocabWord; | ||
| + | import org.deeplearning4j.models.word2vec.Word2Vec; | ||
| + | import org.deeplearning4j.text.sentenceiterator.BasicLineIterator; | ||
| + | import org.deeplearning4j.text.sentenceiterator.SentenceIterator; | ||
| + | import org.deeplearning4j.text.tokenization.tokenizer.preprocessor.CommonPreprocessor; | ||
| + | import org.deeplearning4j.text.tokenization.tokenizerfactory.DefaultTokenizerFactory; | ||
| + | import org.deeplearning4j.text.tokenization.tokenizerfactory.TokenizerFactory; | ||
| + | |||
| + | import java.nio.file.Path; | ||
| + | import java.util.Collection; | ||
| + | import java.util.List; | ||
| + | import java.util.Set; | ||
| + | |||
| + | /** | ||
| + | * Word2Vec 词嵌入示例。 | ||
| + | * 语料为 SIGHAN 2005 中文分词评测公开发布的《人民日报》1998 年 1 月语料 | ||
| + | * (北京大学标注版本),语料本身已按词切分。 | ||
| + | * 程序先过滤标点、数字和停用词,得到每行一句的清洗语料, | ||
| + | * 再用 DeepLearning4J 训练 CBOW 词向量,观察词向量中的语义关系。 | ||
| + | */ | ||
| + | public class WordEmbeddingDemo { | ||
| + | public static void main(String[] args) { | ||
| + | try { | ||
| + | Path rawCorpus = FileUtil.path( | ||
| + | "data", "chapter3", "sighan2005-pku-training.txt"); | ||
| + | Path stopwordFile = FileUtil.path( | ||
| + | "data", "chapter3", "chinese-stopwords-hit.txt"); | ||
| + | Path cleanCorpus = FileUtil.path( | ||
| + | "output", "chapter3", "pku-clean.txt"); | ||
| + | Set<String> stopWords = WordListUtil.readLowerCaseSet(stopwordFile); | ||
| + | int sentenceCount = prepareCorpus(rawCorpus, cleanCorpus, stopWords); | ||
| + | |||
| + | SentenceIterator iterator = new BasicLineIterator(cleanCorpus.toFile()); | ||
| + | // finish() 释放迭代器持有的文件资源。 | ||
| + | try (AutoCloseable iteratorResource = iterator::finish) { | ||
| + | TokenizerFactory tokenizerFactory = new DefaultTokenizerFactory(); | ||
| + | tokenizerFactory.setTokenPreProcessor(new CommonPreprocessor()); | ||
| + | |||
| + | Word2Vec vectorModel = new Word2Vec.Builder() | ||
| + | .minWordFrequency(5) | ||
| + | .epochs(5) | ||
| + | .iterations(1) | ||
| + | .layerSize(100) | ||
| + | .seed(42) | ||
| + | .windowSize(5) | ||
| + | .workers(1) | ||
| + | .negativeSample(10) | ||
| + | .elementsLearningAlgorithm(new CBOW<VocabWord>()) | ||
| + | .iterate(iterator) | ||
| + | .tokenizerFactory(tokenizerFactory) | ||
| + | .build(); | ||
| + | |||
| + | System.out.println("语料来源: 《人民日报》1998年1月(SIGHAN 2005 公开切分语料)"); | ||
| + | System.out.println("停用词文件: " + stopwordFile); | ||
| + | System.out.println("清洗后句行数: " + sentenceCount); | ||
| + | System.out.println("正在训练 Word2Vec 模型..."); | ||
| + | vectorModel.fit(); | ||
| + | |||
| + | String targetWord = "足球"; | ||
| + | if (!vectorModel.hasWord(targetWord)) { | ||
| + | throw new IllegalStateException("语料词表中没有“足球”"); | ||
| + | } | ||
| + | |||
| + | double[] vector = vectorModel.getWordVector(targetWord); | ||
| + | System.out.println("“足球”的向量前 5 维:"); | ||
| + | for (int i = 0; i < Math.min(5, vector.length); i++) { | ||
| + | System.out.printf("%.4f ", vector[i]); | ||
| + | } | ||
| + | System.out.println("..."); | ||
| + | |||
| + | Collection<String> nearest = vectorModel.wordsNearest(targetWord, 5); | ||
| + | System.out.println("与“足球”最相似的词: " + nearest); | ||
| + | printSimilarity(vectorModel, "足球", "篮球"); | ||
| + | printSimilarity(vectorModel, "足球", "排球"); | ||
| + | printSimilarity(vectorModel, "足球", "经济"); | ||
| + | } | ||
| + | |||
| + | } catch (Exception e) { | ||
| + | e.printStackTrace(); | ||
| + | } | ||
| + | } | ||
| + | |||
| + | /** | ||
| + | * 语料清洗:原语料已按词切分,逐行过滤非纯汉字词 | ||
| + | * (标点、全角数字、英文字母等)和停用词,写入每行一句的清洗语料。 | ||
| + | */ | ||
| + | private static int prepareCorpus( | ||
| + | Path source, Path target, Set<String> stopWords) { | ||
| + | List<String> lines = FileUtil.readUtf8Lines(source); | ||
| + | StringBuilder builder = new StringBuilder(); | ||
| + | int sentenceCount = 0; | ||
| + | for (String line : lines) { | ||
| + | StringBuilder cleaned = new StringBuilder(); | ||
| + | for (String token : line.trim().split("\\s+")) { | ||
| + | if (!token.matches("[\\u4e00-\\u9fff]+")) { | ||
| + | continue; | ||
| + | } | ||
| + | if (stopWords.contains(token)) { | ||
| + | continue; | ||
| + | } | ||
| + | if (cleaned.length() > 0) { | ||
| + | cleaned.append(' '); | ||
| + | } | ||
| + | cleaned.append(token); | ||
| + | } | ||
| + | if (cleaned.length() > 0) { | ||
| + | builder.append(cleaned).append(System.lineSeparator()); | ||
| + | sentenceCount++; | ||
| + | } | ||
| + | } | ||
| + | FileUtil.writeUtf8Text(target, builder.toString()); | ||
| + | return sentenceCount; | ||
| + | } | ||
| + | |||
| + | private static void printSimilarity( | ||
| + | Word2Vec model, String left, String right) { | ||
| + | if (model.hasWord(left) && model.hasWord(right)) { | ||
| + | System.out.printf("“%s”与“%s”的余弦相似度: %.4f%n", | ||
| + | left, right, model.similarity(left, right)); | ||
| + | } else { | ||
| + | System.out.println("跳过相似度计算,词表中缺少: " + right); | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | </file> | ||
| + | |||
| + | ===== BERT预训练语言模型 ===== | ||
| + | ==== 第三方库依赖(POM.xml) ==== | ||
| + | <file xml> | ||
| + | <dependency> | ||
| + | <groupId>ai.djl</groupId> | ||
| + | <artifactId>api</artifactId> | ||
| + | <version>0.28.0</version> | ||
| + | </dependency> | ||
| + | <dependency> | ||
| + | <groupId>ai.djl.pytorch</groupId> | ||
| + | <artifactId>pytorch-engine</artifactId> | ||
| + | <version>0.28.0</version> | ||
| + | </dependency> | ||
| + | <dependency> | ||
| + | <groupId>ai.djl.pytorch</groupId> | ||
| + | <artifactId>pytorch-model-zoo</artifactId> | ||
| + | <version>0.28.0</version> | ||
| + | </dependency> | ||
| + | <dependency> | ||
| + | <groupId>ai.djl.huggingface</groupId> | ||
| + | <artifactId>tokenizers</artifactId> | ||
| + | <version>0.28.0</version> | ||
| + | </dependency> | ||
| + | </file> | ||
| + | ==== 源代码 ==== | ||
| + | <file java BertEmbeddingDemo.java> | ||
| + | package cn.edu.bjut.textmining.chapter3; | ||
| + | |||
| + | import ai.djl.Application; | ||
| + | import ai.djl.huggingface.translator.TextEmbeddingTranslatorFactory; | ||
| + | import ai.djl.inference.Predictor; | ||
| + | import ai.djl.repository.zoo.Criteria; | ||
| + | import ai.djl.repository.zoo.ZooModel; | ||
| + | import ai.djl.training.util.ProgressBar; | ||
| + | |||
| + | import java.util.Arrays; | ||
| + | |||
| + | public class BertEmbeddingDemo { | ||
| + | public static void main(String[] args) { | ||
| + | try { | ||
| + | String text1 = "He went to the bank to deposit money."; | ||
| + | String text2 = "He walked along the river bank."; | ||
| + | |||
| + | Criteria<String, float[]> criteria = Criteria.builder() | ||
| + | .optApplication(Application.NLP.TEXT_EMBEDDING) | ||
| + | .setTypes(String.class, float[].class) | ||
| + | .optModelUrls("djl://ai.djl.huggingface.pytorch/" | ||
| + | + "sentence-transformers/all-MiniLM-L6-v2") | ||
| + | .optEngine("PyTorch") | ||
| + | .optTranslatorFactory(new TextEmbeddingTranslatorFactory()) | ||
| + | .optProgress(new ProgressBar()) | ||
| + | .build(); | ||
| + | |||
| + | try (ZooModel<String, float[]> model = criteria.loadModel(); | ||
| + | Predictor<String, float[]> predictor = model.newPredictor()) { | ||
| + | float[] embedding1 = predictor.predict(text1); | ||
| + | float[] embedding2 = predictor.predict(text2); | ||
| + | |||
| + | System.out.println("句子1: " + text1); | ||
| + | System.out.println("向量维度: " + embedding1.length); | ||
| + | System.out.println("前5位: " | ||
| + | + Arrays.toString(Arrays.copyOf(embedding1, 5))); | ||
| + | System.out.println("句子2: " + text2); | ||
| + | System.out.println("向量维度: " + embedding2.length); | ||
| + | System.out.println("前5位: " | ||
| + | + Arrays.toString(Arrays.copyOf(embedding2, 5))); | ||
| + | System.out.printf("余弦相似度: %.4f%n", | ||
| + | cosineSimilarity(embedding1, embedding2)); | ||
| + | } | ||
| + | } catch (Exception e) { | ||
| + | e.printStackTrace(); | ||
| + | } | ||
| + | } | ||
| + | |||
| + | private static double cosineSimilarity(float[] vectorA, float[] vectorB) { | ||
| + | double dotProduct = 0.0; | ||
| + | double normA = 0.0; | ||
| + | double normB = 0.0; | ||
| + | for (int i = 0; i < vectorA.length; i++) { | ||
| + | dotProduct += vectorA[i] * vectorB[i]; | ||
| + | normA += vectorA[i] * vectorA[i]; | ||
| + | normB += vectorB[i] * vectorB[i]; | ||
| + | } | ||
| + | return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); | ||
| + | } | ||
| + | } | ||
| + | </file> | ||
| + | |||
| + | ===== 向量模型 ===== | ||
| + | <file java OpenAICompatibleEmbeddingClient.java> | ||
| + | package cn.edu.bjut.textmining.chapter3; | ||
| + | |||
| + | import com.google.gson.JsonArray; | ||
| + | import com.google.gson.JsonElement; | ||
| + | import com.google.gson.JsonObject; | ||
| + | import com.google.gson.JsonParser; | ||
| + | |||
| + | import java.io.ByteArrayOutputStream; | ||
| + | import java.io.IOException; | ||
| + | import java.io.InputStream; | ||
| + | import java.io.OutputStream; | ||
| + | import java.io.UncheckedIOException; | ||
| + | import java.net.HttpURLConnection; | ||
| + | import java.net.URI; | ||
| + | import java.nio.charset.StandardCharsets; | ||
| + | import java.util.List; | ||
| + | |||
| + | /** 通过 OpenAI compatible API 获取文本向量。 */ | ||
| + | public final class OpenAICompatibleEmbeddingClient { | ||
| + | // 配置直接写在这里,无需设置环境变量。地址末尾不要添加 /embeddings。 | ||
| + | private static final String BASE_URL = | ||
| + | "https://llm-8olx338nm0p2vj5q.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"; | ||
| + | |||
| + | // 这是占位用的假密钥;运行前在自己的电脑上替换为真实密钥。 | ||
| + | private static final String API_KEY = "sk-ws-H.PHL" ; | ||
| + | |||
| + | // qwen3.8-flash 是文本生成模型,无法用于本类的 /embeddings 向量请求。 | ||
| + | // 获取文本向量时,须填写服务支持的向量模型,如 qwen3.7-text-embedding, | ||
| + | // 并将 BASE_URL 和 API_KEY 一起换成有权调用该向量服务的配置。 | ||
| + | // 官方模型列表:https://help.aliyun.com/zh/model-studio/text-embedding-synchronous-api | ||
| + | private static final String MODEL = "qwen3.7-text-embedding"; | ||
| + | |||
| + | private final String baseUrl; | ||
| + | private final String apiKey; | ||
| + | private final String model; | ||
| + | |||
| + | public OpenAICompatibleEmbeddingClient(String baseUrl, String apiKey, String model) { | ||
| + | this.baseUrl = requireText(baseUrl, "BASE_URL").replaceAll("/+$", ""); | ||
| + | this.apiKey = requireText(apiKey, "API_KEY"); | ||
| + | this.model = requireText(model, "MODEL"); | ||
| + | URI uri = URI.create(this.baseUrl); | ||
| + | if (!("https".equals(uri.getScheme()) || "http".equals(uri.getScheme())) | ||
| + | || uri.getHost() == null || uri.getQuery() != null | ||
| + | || uri.getFragment() != null || uri.getUserInfo() != null) { | ||
| + | throw new IllegalArgumentException("BASE_URL 必须是 HTTP(S) API 根地址,不含查询参数"); | ||
| + | } | ||
| + | } | ||
| + | |||
| + | /** | ||
| + | * 供 EmbeddingApiDemo 调用。此方法直接使用上方常量,不读取环境变量。 | ||
| + | */ | ||
| + | public static OpenAICompatibleEmbeddingClient fromEnvironment() { | ||
| + | return new OpenAICompatibleEmbeddingClient(BASE_URL, API_KEY, MODEL); | ||
| + | } | ||
| + | |||
| + | public String getModel() { | ||
| + | return model; | ||
| + | } | ||
| + | |||
| + | /** 返回原始 JSON,调用者可将其保存后检查向量和请求信息。 */ | ||
| + | public String embed(List<String> texts) { | ||
| + | if (texts == null || texts.isEmpty()) { | ||
| + | throw new IllegalArgumentException("输入文本不能为空"); | ||
| + | } | ||
| + | JsonArray input = new JsonArray(); | ||
| + | for (String text : texts) { | ||
| + | input.add(requireText(text, "输入文本")); | ||
| + | } | ||
| + | JsonObject request = new JsonObject(); | ||
| + | request.addProperty("model", model); | ||
| + | request.add("input", input); | ||
| + | request.addProperty("encoding_format", "float"); | ||
| + | byte[] body = request.toString().getBytes(StandardCharsets.UTF_8); | ||
| + | try { | ||
| + | HttpURLConnection connection = (HttpURLConnection) | ||
| + | URI.create(baseUrl + "/embeddings").toURL().openConnection(); | ||
| + | // disconnect() 也在离开代码块时执行,包含请求失败的情况。 | ||
| + | try (AutoCloseable connectionResource = connection::disconnect) { | ||
| + | connection.setRequestMethod("POST"); | ||
| + | connection.setConnectTimeout(15000); | ||
| + | connection.setReadTimeout(60000); | ||
| + | connection.setInstanceFollowRedirects(false); | ||
| + | connection.setDoOutput(true); | ||
| + | connection.setRequestProperty("Authorization", "Bearer " + apiKey); | ||
| + | connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); | ||
| + | connection.setRequestProperty("Accept", "application/json"); | ||
| + | connection.setFixedLengthStreamingMode(body.length); | ||
| + | try (OutputStream output = connection.getOutputStream()) { | ||
| + | output.write(body); | ||
| + | } | ||
| + | int status = connection.getResponseCode(); | ||
| + | boolean success = status >= 200 && status < 300; | ||
| + | String response = readResponse(success | ||
| + | ? connection.getInputStream() : connection.getErrorStream()); | ||
| + | if (!success) { | ||
| + | throw new IllegalStateException("向量 API 返回 HTTP " + status | ||
| + | + ":" + response.replace(apiKey, "[已隐藏密钥]")); | ||
| + | } | ||
| + | return response; | ||
| + | } | ||
| + | } catch (IOException e) { | ||
| + | throw new UncheckedIOException("请求向量 API 失败", e); | ||
| + | } catch (RuntimeException e) { | ||
| + | throw e; | ||
| + | } catch (Exception e) { | ||
| + | throw new IllegalStateException("关闭 HTTP 连接失败", e); | ||
| + | } | ||
| + | } | ||
| + | |||
| + | /** index 对应输入位置;响应数组的排列顺序不一定等于输入顺序。 */ | ||
| + | public static double[][] parseEmbeddings(String response, int inputCount) { | ||
| + | if (inputCount <= 0) { | ||
| + | throw new IllegalArgumentException("输入数量必须为正整数"); | ||
| + | } | ||
| + | try { | ||
| + | JsonArray data = JsonParser.parseString(response) | ||
| + | .getAsJsonObject().getAsJsonArray("data"); | ||
| + | if (data == null || data.size() != inputCount) { | ||
| + | throw new IllegalArgumentException("返回向量数量与输入数量不一致"); | ||
| + | } | ||
| + | double[][] vectors = new double[inputCount][]; | ||
| + | int dimension = -1; | ||
| + | for (JsonElement element : data) { | ||
| + | JsonObject item = element.getAsJsonObject(); | ||
| + | int index = item.get("index").getAsBigDecimal().intValueExact(); | ||
| + | if (index < 0 || index >= inputCount || vectors[index] != null) { | ||
| + | throw new IllegalArgumentException("响应 index 越界或重复:" + index); | ||
| + | } | ||
| + | JsonArray embedding = item.getAsJsonArray("embedding"); | ||
| + | if (embedding == null || embedding.size() == 0) { | ||
| + | throw new IllegalArgumentException("返回了空向量"); | ||
| + | } | ||
| + | if (dimension == -1) { | ||
| + | dimension = embedding.size(); | ||
| + | } | ||
| + | if (embedding.size() != dimension) { | ||
| + | throw new IllegalArgumentException("返回向量的维数不一致"); | ||
| + | } | ||
| + | vectors[index] = new double[dimension]; | ||
| + | double norm = 0.0; | ||
| + | for (int j = 0; j < dimension; j++) { | ||
| + | double value = embedding.get(j).getAsDouble(); | ||
| + | if (!Double.isFinite(value)) { | ||
| + | throw new IllegalArgumentException("向量中存在非有限数值"); | ||
| + | } | ||
| + | vectors[index][j] = value; | ||
| + | norm = Math.hypot(norm, value); | ||
| + | } | ||
| + | if (norm == 0.0 || !Double.isFinite(norm)) { | ||
| + | throw new IllegalArgumentException("向量范数必须是有限正数"); | ||
| + | } | ||
| + | } | ||
| + | for (double[] vector : vectors) { | ||
| + | if (vector == null) { | ||
| + | throw new IllegalArgumentException("响应缺少输入对应的向量"); | ||
| + | } | ||
| + | } | ||
| + | return vectors; | ||
| + | } catch (RuntimeException e) { | ||
| + | throw new IllegalArgumentException("无法解析有效的 embeddings 响应", e); | ||
| + | } | ||
| + | } | ||
| + | |||
| + | private static String readResponse(InputStream stream) { | ||
| + | if (stream == null) { | ||
| + | return ""; | ||
| + | } | ||
| + | try (InputStream input = stream; | ||
| + | ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { | ||
| + | byte[] bytes = new byte[4096]; | ||
| + | for (int count; (count = input.read(bytes)) != -1; ) { | ||
| + | buffer.write(bytes, 0, count); | ||
| + | } | ||
| + | return new String(buffer.toByteArray(), StandardCharsets.UTF_8); | ||
| + | } catch (IOException e) { | ||
| + | throw new UncheckedIOException("读取向量 API 响应失败", e); | ||
| + | } | ||
| + | } | ||
| + | |||
| + | private static String requireText(String value, String name) { | ||
| + | if (value == null || value.trim().isEmpty()) { | ||
| + | throw new IllegalArgumentException("请设置非空的 " + name); | ||
| + | } | ||
| + | return value.trim(); | ||
| + | } | ||
| + | } | ||
| + | </file> | ||
| + | ===== 上机作业 ===== | ||
| + | -给定一段文本(中文、英文均可),实现特征选择方法(熵、相对熵、$\chi^2$统计量和互信息),并完成特征选择。 | ||
| + | -利用第三方库,完成GloVe词嵌入的训练。 | ||
| ~~DISCUSSION~~ | ~~DISCUSSION~~ | ||