这里会显示出您选择的修订版和当前版本之间的差别。
| 两侧同时换到之前的修订记录 前一修订版 后一修订版 | 前一修订版 | ||
|
zh:courses:textmining2026:ch05 [2026/10/12 15:10] pzczxs [支持类] |
zh:courses:textmining2026:ch05 [2026/10/20 07:27] (当前版本) pzczxs [AP聚类方法] |
||
|---|---|---|---|
| 行 1: | 行 1: | ||
| ====== 第五章:文本聚类 ====== | ====== 第五章:文本聚类 ====== | ||
| ===== 课件 ===== | ===== 课件 ===== | ||
| - | 下载:文本聚类 | + | 下载:{{ :zh:courses:textmining2026:ch05.pptx |文本聚类}} |
| ===== 数据集 ===== | ===== 数据集 ===== | ||
| - | 下载: | + | 下载:{{ :zh:courses:textmining2026:chapter5.zip |}} |
| ===== 工具类 ===== | ===== 工具类 ===== | ||
| 行 373: | 行 373: | ||
| } | } | ||
| lines(prefix + "_vectors.csv", rows); | lines(prefix + "_vectors.csv", rows); | ||
| + | } | ||
| + | } | ||
| + | </file> | ||
| + | |||
| + | <file java TNewsCorpusLoader.java> | ||
| + | package cn.edu.bjut.textmining.chapter5.support; | ||
| + | |||
| + | import java.io.BufferedReader; | ||
| + | import java.io.IOException; | ||
| + | import java.io.UncheckedIOException; | ||
| + | import java.nio.charset.StandardCharsets; | ||
| + | import java.nio.file.Files; | ||
| + | import java.nio.file.Path; | ||
| + | import java.text.Normalizer; | ||
| + | import java.util.ArrayList; | ||
| + | import java.util.HashMap; | ||
| + | import java.util.HashSet; | ||
| + | import java.util.LinkedHashMap; | ||
| + | import java.util.List; | ||
| + | import java.util.Locale; | ||
| + | import java.util.Map; | ||
| + | import java.util.Set; | ||
| + | import java.util.regex.Matcher; | ||
| + | import java.util.regex.Pattern; | ||
| + | |||
| + | /** 读取官方 JSONL;类别仅用于选择样本和事后评价。 */ | ||
| + | public final class TNewsCorpusLoader { | ||
| + | private static final Pattern FIELD = | ||
| + | Pattern.compile("\\\"(label_desc|sentence)\\\"\\s*:\\s*\\\"((?:\\\\.|[^\\\"])*)\\\""); | ||
| + | |||
| + | private TNewsCorpusLoader() {} | ||
| + | |||
| + | public static final class NewsRecord { | ||
| + | private final String label; | ||
| + | private final String title; | ||
| + | private final int sourceLine; | ||
| + | |||
| + | public NewsRecord(String label, String title) { | ||
| + | this(label, title, 0); | ||
| + | } | ||
| + | |||
| + | public NewsRecord(String label, String title, int sourceLine) { | ||
| + | this.label = label; | ||
| + | this.title = title; | ||
| + | this.sourceLine = sourceLine; | ||
| + | } | ||
| + | |||
| + | public String getLabel() { | ||
| + | return label; | ||
| + | } | ||
| + | |||
| + | public String getTitle() { | ||
| + | return title; | ||
| + | } | ||
| + | |||
| + | public int getSourceLine() { | ||
| + | return sourceLine; | ||
| + | } | ||
| + | } | ||
| + | |||
| + | public static List<NewsRecord> readAll(Path file) { | ||
| + | List<NewsRecord> records = new ArrayList<>(); | ||
| + | try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { | ||
| + | String line; | ||
| + | int number = 0; | ||
| + | while ((line = reader.readLine()) != null) { | ||
| + | number++; | ||
| + | Matcher matcher = FIELD.matcher(line); | ||
| + | Map<String, String> values = new HashMap<>(); | ||
| + | while (matcher.find()) { | ||
| + | values.put(matcher.group(1), unescape(matcher.group(2))); | ||
| + | } | ||
| + | if (!values.containsKey("sentence") || !values.containsKey("label_desc")) { | ||
| + | throw new IllegalArgumentException("JSONL缺少字段,行号:" + number); | ||
| + | } | ||
| + | records.add( | ||
| + | new NewsRecord(values.get("label_desc"), values.get("sentence"), number)); | ||
| + | } | ||
| + | } catch (IOException e) { | ||
| + | throw new UncheckedIOException("读取新闻失败:" + file, e); | ||
| + | } | ||
| + | return records; | ||
| + | } | ||
| + | |||
| + | public static String normalizedKey(String title) { | ||
| + | return Normalizer.normalize(title, Normalizer.Form.NFKC) | ||
| + | .replaceAll("[\\s\\p{Z}]+", "") | ||
| + | .toLowerCase(Locale.ROOT); | ||
| + | } | ||
| + | |||
| + | public static List<NewsRecord> readBalanced(Path file, List<String> categories, int limit) { | ||
| + | if (limit < 1) { | ||
| + | throw new IllegalArgumentException("每类样本数必须为正数"); | ||
| + | } | ||
| + | Map<String, NewsRecord> unique = new LinkedHashMap<>(); | ||
| + | Set<String> conflicts = new HashSet<>(); | ||
| + | for (NewsRecord record : readAll(file)) { | ||
| + | String key = normalizedKey(record.title); | ||
| + | NewsRecord old = unique.get(key); | ||
| + | if (old != null && !old.label.equals(record.label)) { | ||
| + | conflicts.add(key); | ||
| + | } | ||
| + | if (old == null && !key.isEmpty()) { | ||
| + | unique.put(key, record); | ||
| + | } | ||
| + | } | ||
| + | Map<String, Integer> counts = new LinkedHashMap<>(); | ||
| + | for (String category : categories) { | ||
| + | counts.put(category, 0); | ||
| + | } | ||
| + | List<NewsRecord> result = new ArrayList<>(); | ||
| + | for (Map.Entry<String, NewsRecord> item : unique.entrySet()) { | ||
| + | NewsRecord record = item.getValue(); | ||
| + | Integer count = counts.get(record.label); | ||
| + | if (!conflicts.contains(item.getKey()) && count != null && count < limit) { | ||
| + | result.add(record); | ||
| + | counts.put(record.label, count + 1); | ||
| + | } | ||
| + | } | ||
| + | for (String category : categories) { | ||
| + | if (counts.get(category) != limit) { | ||
| + | throw new IllegalStateException("样本不足:" + category); | ||
| + | } | ||
| + | } | ||
| + | return result; | ||
| + | } | ||
| + | |||
| + | public static List<NewsRecord> readCuratedSample(Path file, int perCategory) { | ||
| + | if (perCategory < 1 || perCategory > 3) { | ||
| + | throw new IllegalArgumentException("每类选择1至3条"); | ||
| + | } | ||
| + | int[] lines = {208, 1241, 1431, 625, 676, 824, 274, 308, 566}; | ||
| + | List<NewsRecord> all = readAll(file); | ||
| + | List<NewsRecord> result = new ArrayList<>(); | ||
| + | for (int group = 0; group < 3; group++) { | ||
| + | for (int j = 0; j < perCategory; j++) { | ||
| + | result.add(all.get(lines[group * 3 + j] - 1)); | ||
| + | } | ||
| + | } | ||
| + | return result; | ||
| + | } | ||
| + | |||
| + | public static List<String> titles(List<NewsRecord> records) { | ||
| + | List<String> titles = new ArrayList<>(); | ||
| + | for (NewsRecord record : records) { | ||
| + | titles.add(record.title); | ||
| + | } | ||
| + | return titles; | ||
| + | } | ||
| + | |||
| + | private static String unescape(String text) { | ||
| + | StringBuilder result = new StringBuilder(); | ||
| + | for (int i = 0; i < text.length(); i++) { | ||
| + | char c = text.charAt(i); | ||
| + | if (c != '\\') { | ||
| + | result.append(c); | ||
| + | continue; | ||
| + | } | ||
| + | if (++i >= text.length()) { | ||
| + | throw new IllegalArgumentException("JSON转义不完整"); | ||
| + | } | ||
| + | c = text.charAt(i); | ||
| + | switch (c) { | ||
| + | case 'u': | ||
| + | result.append((char) Integer.parseInt(text.substring(i + 1, i + 5), 16)); | ||
| + | i += 4; | ||
| + | break; | ||
| + | case 'n': | ||
| + | result.append('\n'); | ||
| + | break; | ||
| + | case 'r': | ||
| + | result.append('\r'); | ||
| + | break; | ||
| + | case 't': | ||
| + | result.append('\t'); | ||
| + | break; | ||
| + | case 'b': | ||
| + | result.append('\b'); | ||
| + | break; | ||
| + | case 'f': | ||
| + | result.append('\f'); | ||
| + | break; | ||
| + | case '"': | ||
| + | case '\\': | ||
| + | case '/': | ||
| + | result.append(c); | ||
| + | break; | ||
| + | default: | ||
| + | throw new IllegalArgumentException("未知JSON转义:" + c); | ||
| + | } | ||
| + | } | ||
| + | return result.toString(); | ||
| + | } | ||
| + | } | ||
| + | </file> | ||
| + | |||
| + | <file java TextFeatures.java> | ||
| + | package cn.edu.bjut.textmining.chapter5.support; | ||
| + | |||
| + | import java.util.ArrayList; | ||
| + | import java.util.Arrays; | ||
| + | import java.util.Collections; | ||
| + | import java.util.HashMap; | ||
| + | import java.util.HashSet; | ||
| + | import java.util.List; | ||
| + | import java.util.Locale; | ||
| + | import java.util.Map; | ||
| + | import java.util.Set; | ||
| + | |||
| + | import cn.edu.bjut.textmining.util.TextVectorizer; | ||
| + | |||
| + | /** 字符N-gram TF-IDF;所有文档共用一个词表和同一组DF。 */ | ||
| + | public final class TextFeatures { | ||
| + | private TextFeatures() {} | ||
| + | |||
| + | private static final Set<String> STOP_GRAMS = | ||
| + | new HashSet<>( | ||
| + | Arrays.asList( | ||
| + | "什么", "怎么", "如何", "为什么", "一个", "一种", "可以", "哪些", "有没有", "是不是", "这个", | ||
| + | "这样", "中国", "现在", "我们", "他们", "你们", "自己", "的人", "的是", "知道", "多少", "进行", | ||
| + | "问题", "时候", "为何", "来看", "认为")); | ||
| + | |||
| + | public static final class Result { | ||
| + | public final double[][] vectors; | ||
| + | public final List<String> vocabulary; | ||
| + | public final int[] documentFrequencies; | ||
| + | |||
| + | Result(double[][] vectors, List<String> vocabulary, int[] df) { | ||
| + | this.vectors = vectors; | ||
| + | this.vocabulary = vocabulary; | ||
| + | this.documentFrequencies = df; | ||
| + | } | ||
| + | } | ||
| + | |||
| + | public static List<String> ngrams(String text, int minN, int maxN, boolean filter) { | ||
| + | // 标点、符号和空白划定片段边界,不把两侧字符拼成原文中不存在的组合。 | ||
| + | String clean = | ||
| + | java.text.Normalizer.normalize(text, java.text.Normalizer.Form.NFKC) | ||
| + | .toLowerCase(Locale.ROOT); | ||
| + | List<String> result = new ArrayList<>(); | ||
| + | for (String segment : clean.split("[\\p{P}\\p{S}\\s]+")) { | ||
| + | int[] codePoints = segment.codePoints().toArray(); | ||
| + | for (int n = minN; n <= maxN; n++) { | ||
| + | for (int i = 0; i + n <= codePoints.length; i++) { | ||
| + | String gram = new String(codePoints, i, n); | ||
| + | if (!filter || (!STOP_GRAMS.contains(gram) && !gram.matches("[0-9]+"))) { | ||
| + | result.add(gram); | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | return result; | ||
| + | } | ||
| + | |||
| + | public static Result fit(List<String> documents, boolean largeCorpus) { | ||
| + | if (documents.isEmpty()) { | ||
| + | throw new IllegalArgumentException("语料不能为空"); | ||
| + | } | ||
| + | Map<String, Integer> frequency = new HashMap<>(); | ||
| + | List<List<String>> tokenized = new ArrayList<>(); | ||
| + | for (String document : documents) { | ||
| + | List<String> grams = ngrams(document, largeCorpus ? 2 : 1, largeCorpus ? 3 : 2, largeCorpus); | ||
| + | tokenized.add(grams); | ||
| + | for (String gram : new HashSet<>(grams)) { | ||
| + | frequency.put(gram, frequency.getOrDefault(gram, 0) + 1); | ||
| + | } | ||
| + | } | ||
| + | List<String> vocabulary = new ArrayList<>(frequency.keySet()); | ||
| + | if (largeCorpus) { | ||
| + | vocabulary.removeIf( | ||
| + | gram -> | ||
| + | frequency.get(gram) < 2 | ||
| + | || frequency.get(gram) > documents.size() * 0.60); | ||
| + | vocabulary.sort( | ||
| + | (a, b) -> { | ||
| + | int order = frequency.get(b).compareTo(frequency.get(a)); | ||
| + | return order != 0 ? order : a.compareTo(b); | ||
| + | }); | ||
| + | if (vocabulary.size() > 5000) { | ||
| + | vocabulary = new ArrayList<>(vocabulary.subList(0, 5000)); | ||
| + | } | ||
| + | } else { | ||
| + | Collections.sort(vocabulary); | ||
| + | } | ||
| + | Map<String, Integer> index = new HashMap<>(); | ||
| + | int[] df = new int[vocabulary.size()]; | ||
| + | for (int j = 0; j < vocabulary.size(); j++) { | ||
| + | index.put(vocabulary.get(j), j); | ||
| + | df[j] = frequency.get(vocabulary.get(j)); | ||
| + | } | ||
| + | double[][] vectors = new double[documents.size()][vocabulary.size()]; | ||
| + | for (int i = 0; i < documents.size(); i++) { | ||
| + | for (String gram : tokenized.get(i)) { | ||
| + | Integer j = index.get(gram); | ||
| + | if (j != null) { | ||
| + | vectors[i][j]++; | ||
| + | } | ||
| + | } | ||
| + | for (int j = 0; j < vocabulary.size(); j++) { | ||
| + | double idf = Math.log((documents.size() + 1.0) / (df[j] + 1.0)) + 1.0; | ||
| + | vectors[i][j] *= idf; | ||
| + | } | ||
| + | TextVectorizer.normalize(vectors[i]); | ||
| + | } | ||
| + | return new Result(vectors, vocabulary, df); | ||
| } | } | ||
| } | } | ||
| 行 673: | 行 978: | ||
| } | } | ||
| </file> | </file> | ||
| + | |||
| + | ===== AP聚类方法 ===== | ||
| + | <file java AffinityPropagation.java> | ||
| + | package cn.edu.bjut.textmining.chapter5; | ||
| + | |||
| + | import java.util.ArrayList; | ||
| + | import java.util.Arrays; | ||
| + | import java.util.List; | ||
| + | |||
| + | /** s(i,k)=-||x_i-x_k||²;责任度和可用度采用同步更新与阻尼。 */ | ||
| + | public final class AffinityPropagation { | ||
| + | private AffinityPropagation() {} | ||
| + | |||
| + | public static final class Result { | ||
| + | public final int[] labels; | ||
| + | public final int[] exemplars; | ||
| + | public final int iterations; | ||
| + | public final boolean converged; | ||
| + | public final double preference; | ||
| + | |||
| + | Result( | ||
| + | int[] labels, | ||
| + | int[] exemplars, | ||
| + | int iterations, | ||
| + | boolean converged, | ||
| + | double preference) { | ||
| + | this.labels = labels; | ||
| + | this.exemplars = exemplars; | ||
| + | this.iterations = iterations; | ||
| + | this.converged = converged; | ||
| + | this.preference = preference; | ||
| + | } | ||
| + | } | ||
| + | |||
| + | public static double[][] similarities(double[][] vectors) { | ||
| + | KMeansEngine.validate(vectors); | ||
| + | double[][] s = new double[vectors.length][vectors.length]; | ||
| + | for (int i = 0; i < s.length; i++) { | ||
| + | for (int k = 0; k < s.length; k++) { | ||
| + | for (int j = 0; j < vectors[i].length; j++) { | ||
| + | double delta = vectors[i][j] - vectors[k][j]; | ||
| + | s[i][k] -= delta * delta; | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | return s; | ||
| + | } | ||
| + | |||
| + | public static double medianPreference(double[][] s) { | ||
| + | if (s.length < 2) { | ||
| + | throw new IllegalArgumentException("AP至少需要两个样本"); | ||
| + | } | ||
| + | double[] values = new double[s.length * (s.length - 1)]; | ||
| + | int p = 0; | ||
| + | for (int i = 0; i < s.length; i++) { | ||
| + | for (int k = 0; k < s.length; k++) { | ||
| + | if (i != k) { | ||
| + | values[p++] = s[i][k]; | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | Arrays.sort(values); | ||
| + | return (values[(p - 1) / 2] + values[p / 2]) / 2; | ||
| + | } | ||
| + | |||
| + | public static Result fit( | ||
| + | double[][] vectors, double damping, int maxIterations, int stableRounds) { | ||
| + | double[][] s = similarities(vectors); | ||
| + | int n = s.length; | ||
| + | double preference = medianPreference(s); | ||
| + | if (damping < 0.5 || damping >= 1 || maxIterations < 1 || stableRounds < 1) { | ||
| + | throw new IllegalArgumentException("请检查阻尼、迭代上限和稳定轮数"); | ||
| + | } | ||
| + | for (int i = 0; i < n; i++) { | ||
| + | s[i][i] = preference; | ||
| + | } | ||
| + | double[][] r = new double[n][n], a = new double[n][n]; | ||
| + | boolean[] previous = new boolean[n]; | ||
| + | int stable = 0, completed = 0; | ||
| + | boolean converged = false; | ||
| + | for (int iteration = 0; iteration < maxIterations; iteration++) { | ||
| + | completed++; | ||
| + | updateResponsibilities(s, r, a, damping); | ||
| + | updateAvailabilities(r, a, damping); | ||
| + | boolean[] current = new boolean[n]; | ||
| + | int count = 0; | ||
| + | for (int k = 0; k < n; k++) { | ||
| + | current[k] = r[k][k] + a[k][k] > 0; | ||
| + | if (current[k]) { | ||
| + | count++; | ||
| + | } | ||
| + | } | ||
| + | stable = Arrays.equals(previous, current) ? stable + 1 : 0; | ||
| + | previous = current; | ||
| + | if (count > 0 && stable >= stableRounds) { | ||
| + | converged = true; | ||
| + | break; | ||
| + | } | ||
| + | } | ||
| + | List<Integer> chosen = new ArrayList<>(); | ||
| + | for (int k = 0; k < n; k++) { | ||
| + | if (r[k][k] + a[k][k] > 0) { | ||
| + | chosen.add(k); | ||
| + | } | ||
| + | } | ||
| + | if (chosen.isEmpty()) { | ||
| + | throw new IllegalStateException("AP尚未产生代表点,请调整偏好、阻尼或迭代上限"); | ||
| + | } | ||
| + | int[] labels = new int[n], exemplars = new int[chosen.size()]; | ||
| + | for (int c = 0; c < exemplars.length; c++) { | ||
| + | exemplars[c] = chosen.get(c); | ||
| + | } | ||
| + | // 以原始相似度分配给代表点;代表点归入自身簇。 | ||
| + | for (int i = 0; i < n; i++) { | ||
| + | double best = -Double.MAX_VALUE; | ||
| + | for (int c = 0; c < exemplars.length; c++) { | ||
| + | if (s[i][exemplars[c]] > best) { | ||
| + | best = s[i][exemplars[c]]; | ||
| + | labels[i] = c; | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | for (int c = 0; c < exemplars.length; c++) { | ||
| + | labels[exemplars[c]] = c; | ||
| + | } | ||
| + | return new Result(labels, exemplars, completed, converged, preference); | ||
| + | } | ||
| + | |||
| + | public static void updateResponsibilities( | ||
| + | double[][] s, double[][] r, double[][] a, double damping) { | ||
| + | for (int i = 0; i < s.length; i++) { | ||
| + | for (int k = 0; k < s.length; k++) { | ||
| + | double maxOther = -Double.MAX_VALUE; | ||
| + | for (int j = 0; j < s.length; j++) { | ||
| + | if (j != k) { | ||
| + | maxOther = Math.max(maxOther, a[i][j] + s[i][j]); | ||
| + | } | ||
| + | } | ||
| + | double next = s[i][k] - maxOther; | ||
| + | r[i][k] = damping * r[i][k] + (1 - damping) * next; | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | |||
| + | public static void updateAvailabilities(double[][] r, double[][] a, double damping) { | ||
| + | for (int k = 0; k < r.length; k++) { | ||
| + | double positiveSum = 0; | ||
| + | for (int i = 0; i < r.length; i++) { | ||
| + | if (i != k) { | ||
| + | positiveSum += Math.max(0, r[i][k]); | ||
| + | } | ||
| + | } | ||
| + | for (int i = 0; i < r.length; i++) { | ||
| + | double next = | ||
| + | i == k | ||
| + | ? positiveSum | ||
| + | : Math.min(0, r[k][k] + positiveSum - Math.max(0, r[i][k])); | ||
| + | a[i][k] = damping * a[i][k] + (1 - damping) * next; | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | </file> | ||
| + | |||
| + | <file java AffinityPropagationDemo.java> | ||
| + | package cn.edu.bjut.textmining.chapter5; | ||
| + | |||
| + | import cn.edu.bjut.textmining.chapter5.support.SampleNews; | ||
| + | |||
| + | /** 在同一TF-IDF空间中传播消息并选取新闻代表。 */ | ||
| + | public class AffinityPropagationDemo { | ||
| + | public static void main(String[] args) { | ||
| + | try { | ||
| + | double[][] vectors = SampleNews.vectors(); | ||
| + | AffinityPropagation.Result result = AffinityPropagation.fit(vectors, 0.7, 300, 20); | ||
| + | System.out.printf( | ||
| + | "偏好值=%.9f,迭代轮数=%d,收敛=%s%n", | ||
| + | result.preference, result.iterations, result.converged); | ||
| + | System.out.print("代表点:"); | ||
| + | for (int i : result.exemplars) { | ||
| + | System.out.print("N" + (i + 1) + " "); | ||
| + | } | ||
| + | System.out.println(); | ||
| + | SampleNews.printClusters(result.labels); | ||
| + | } catch (Exception e) { | ||
| + | e.printStackTrace(); | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | </file> | ||
| + | |||
| + | ===== 上机作业 ===== | ||
| + | * 完成每类(内部度量法、外部度量法和基于聚类稳定性的方法)聚类性能评价中的至少一种。 | ||
| + | |||
| + | ~~DISCUSSION~~ | ||