背景
这是一个比较有意思的功能,说下这个项目的背景,一个政府项目,我们会定时爬取一些国家网站的新闻信息,根据新闻的内容,自动的推送到相关的企业。首先客户没有参照的历史数据,即哪些政策新闻可以推送到哪些企业,已有的就是哪些企业可以关联哪些标签,那么如何做到智能化的推送,就是这个项目的难点。先说下总的流程,大概分为以下几步:
- 爬虫
- 数据预处理
- 维护企业标签
- 文本分词
- 相似性计算
- 新闻推送
处理逻辑
整体的处理流程就是利用爬虫爬取所属于的网站数据,拿到数据之后对数据进行数据的清洗,加工,再根据企业已有的标签和新闻文本做相似度计算。选取一些最相似或者相似度大于某个值的标签。上面步骤里,其他的实现起来相对简单,主要是怎么分词以及怎么做相似度计算才是重点。实际上如果只是根据标签的相似度来计算的话,还是不能满足要求,因为一个标签可能包含了很多子项,或者其他的意思。举个例子,我们系统中有”大规模企业“,”小规模企业“这两个标签,那么怎么样的才算大规模企业和小规模企业?如果不给出具体的标准是没法区分,当然客户肯定是知道,就比如5000人以上的,或者注册资金在1000万以上的就是大规模企业,反之就是小规模企业,拿到这些标准之后并不需要把这些标准放入标签库,而是把这些标准跟原本的标签关联上,算是同义词,没每次做标签的相似度匹配的时候,再将它的同义词做一轮匹配,下面看下我们的具体处理步骤
爬虫
第一步就是数据的来源,首先利用爬虫工具将我们指定的网站新闻内容爬取过来。Java有很多爬虫相关的库,如jsoup,WebMagic,HtmlUnit。具体的如何处理爬取过来的数据看自己的业务需要。爬虫需要懂一些前端相关的技术,说白了就是如何解析html,了解JavaScript的执行流程,这样才能更容易的获取需要的数据,这是一个技术含量较低且繁琐的过程。
示例:
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.io.IOException;
public class HtmlParserExample {
public static void main(String[] args) {
try {
// Replace with the URL of the web page you want to scrape
String url = "https://example.com";
// Connect to the web page and parse its HTML
Document doc = Jsoup.connect(url).get();
// Extract text content from specific HTML elements (e.g., paragraphs)
Elements paragraphs = doc.select("p");
for (Element paragraph : paragraphs) {
String text = paragraph.text();
System.out.println(text);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
数据预处理
这一步也是一个繁琐的过程。
- 对政策文本进行预处理,转换为小写、去除停用词等。
- HTML数据通常包含各种标签(如
<p>、<a>、<div>
等),某些特殊字符可能在HTML中以转义字符(如<表示”<”)或HTML实体(如&表示”&”)的形式存在,需要将这些标签从文本中去除,以保留纯文本内容。 - 检查文本中是否存在特殊字符、转义字符,并将它们正确处理成对应的字符。去除多余的空白字符、标点符号和特殊符号。
- Unicode字符可能在文本中以\uXXXX(其中XXXX为Unicode编码)的形式存在。您可以使用Java的StringEscapeUtils类或类似工具,将Unicode字符转换为对应的字符。
示例:
import org.apache.commons.text.StringEscapeUtils;
public class SpecialCharacterExample {
public static void main(String[] args) {
String text = "This is an example with special characters & Unicode \\u0026.";
// Convert HTML entities to corresponding characters
String decodedText = StringEscapeUtils.unescapeHtml4(text);
// Convert Unicode characters to corresponding characters
String unicodeDecodedText = StringEscapeUtils.unescapeJava(decodedText);
System.out.println(unicodeDecodedText);
}
}
图片数据的处理
在这里我们有个特殊的需求,就是附件的pdf有可能是正文的内容。可能会解析pdf,普通的pdf解析没有问题,就是图片的pdf需要OCR来处理,Java开源的ORC工具还是比较少的,大多数都需要收费,这是推荐一个Tess4J,算是众多开源较好的,开源的还是有些识别乱码的问题,这个在我们项目里暂时还没有解决。
使用示例:
import net.sourceforge.tess4j.ITesseract;
import net.sourceforge.tess4j.Tesseract;
import net.sourceforge.tess4j.TesseractException;
import java.io.File;
public class Tess4JExample {
public static void main(String[] args) {
// 指定Tesseract的OCR数据文件目录,需要根据你的系统进行设置
// 例如:System.setProperty("java.library.path", "path/to/tessdata");
// 在这里,我们将假设tessdata目录在项目的根目录下。
System.setProperty("java.library.path", "./");
// 图像文件的路径
String imagePath = "path/to/your/image.png";
// 创建Tesseract实例
ITesseract tesseract = new Tesseract();
try {
// 使用Tesseract进行图像文本识别
String result = tesseract.doOCR(new File(imagePath));
// 输出识别结果
System.out.println("识别结果:");
System.out.println(result);
} catch (TesseractException e) {
System.err.println("OCR识别出现错误: " + e.getMessage());
}
}
}
维护企业标签
系统里维护一些跟企业相关的标签库,就是标签和企业关联上,某些企业有哪些标签,这些都是提前已知的,说白了就是表和表之间的关联。
分词
到了这一步,说明所需的前奏工作已经准备完成。现在就是选择什么分词器以及如何做相似度计算,首先看看可以选择的分词器,在Java中有很多分词的词库,如著名的jieba分词器,IK Analyzer,以及NLP相关HanLP和Stanford NLP,根据自己所需进行选择。
推荐HanLP,国产NLP处理工具,简单易懂,以下是简单的示例:
import com.hankcs.hanlp.HanLP;
import com.hankcs.hanlp.seg.Segment;
import com.hankcs.hanlp.seg.common.Term;
import java.util.List;
public class HanLPTokenizationExample {
public static void main(String[] args) {
String text = "HanLP是一款自然语言处理工具包。";
// 创建分词器实例
Segment segment = HanLP.newSegment();
// 对文本进行分词
List<Term> termList = segment.seg(text);
// 遍历分词结果
for (Term term : termList) {
System.out.println("单词:" + term.word);
System.out.println("词性:" + term.nature.toString());
System.out.println();
}
}
}
相似性计算
计算文本向量
那么如何做相似性计算呢?首选我们将文本分词之后,再将标签进行分词,将各自转化为 TF-IDF 特征矩阵,然后根据 TF-IDF的矩阵向量使用余弦相似度来计算企业文本与每个标签之间的相似度(在这里我们是先将文本进行分段,把每一段跟标签做相似度计算,因为单个文本的大小可能太大而不能得到很好的结果)
计算余弦相似度
余弦相似度计算公式为:cosine_similarity = (企业向量 · 标签向量) / (||企业向量|| * ||标签向量||)
余弦相似度衡量两个向量之间的夹角,范围在-1到1之间,越接近1表示相似度越高。如果不了解余弦相似度的可自行百度
也可以尝试其他相似性度量方法,例如欧氏距离、Jaccard 相似性等
选择合理的阈值
针对每个标签,计算其与企业文本的余弦相似度。找到大于某些阈值的标签,将其视为与企业最相似的标签
实现
具体代码如下:
import com.google.common.util.concurrent.AtomicDouble;
import com.hankcs.hanlp.dictionary.CoreSynonymDictionary;
import com.hankcs.hanlp.dictionary.stopword.CoreStopWordDictionary;
import com.hankcs.hanlp.seg.Segment;
import com.hankcs.hanlp.seg.common.Term;
import com.hankcs.hanlp.tokenizer.StandardTokenizer;
import com.hankcs.hanlp.utility.Predefine;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/**
* 判定方式:余弦相似度,通过计算两个向量的夹角余弦值来评估他们的相似度 余弦夹角原理: 向量a=(x1,y1),向量b=(x2,y2) similarity=a.b/|a|*|b| a.b=x1x2+y1y2
* |a|=根号[(x1)^2+(y1)^2],|b|=根号[(x2)^2+(y2)^2]*/
@Component
public class CosineSimilarity {
protected static final Logger LOGGER = LoggerFactory.getLogger(CosineSimilarity.class);
@Value("${sysDir.hanLpProperties}")
private String hanLpProperties;
public static Segment segment ;
//初始化分词工具
public void init(){
if (segment==null){
Predefine.HANLP_PROPERTIES_PATH=hanLpProperties;
segment = StandardTokenizer.SEGMENT;
segment.enablePartOfSpeechTagging(true);
segment.enableCustomDictionary(true);
segment.enableCustomDictionaryForcing(true);
segment.enablePlaceRecognize(true);
}
}
/**
* 1、计算两个字符串的相似度
*/
public double getSimilarity(String text1, String text2,List<Term> terms3 ) {
init();
//空字符为0
if (StringUtils.isBlank(text1) || StringUtils.isBlank(text2)) {
return 0.0;
}
if (text1.equalsIgnoreCase(text2)) {
return 1.0;
}
//第一步:进行分词
List<Term> terms1 = segment.seg(text1); //对政策文本再清洗
List<Term> terms2 = segment.seg(text2);
HashSet<Term> set = new HashSet<>();
for (int i = 0; i < terms1.size(); i++) {
Term term = terms1.get(i);
if (CoreStopWordDictionary.contains(term.word)
|| term.nature.startsWith("w")
|| term.nature.startsWith("m")
|| term.nature.startsWith("q")
|| term.nature.startsWith("t")
){
terms1.remove(term);
i--;
continue;
}
// for (int i1 = 0; i1 < terms2.size(); i1++) {
// Term term2 = terms2.get(i1);
// double similarity = CoreSynonymDictionary.similarity(term2.word, term.word);
// if (similarity>0.85){ //近义词将加入标签
// Term tem = new Term(term.word,term2.nature);
// set.add(tem);
// }
//
// }
}
terms2.addAll(set);//加入同义词,增加匹配相似度
terms3.addAll(terms2);
List<Word> words1= terms1.stream().map(term -> new Word(term.word, term.nature.toString())).collect(Collectors.toList());
List<Word> words2= terms3.stream().map(term -> new Word(term.word, term.nature.toString())).collect(Collectors.toList());
if (words1.size()==0|| words2.size()==0){
return 0.0;
}
return getSimilarity(words1, words2);
}
/**
* 2、对于计算出的相似度保留小数点后六位
*/
public double getSimilarity(List<Word> words1, List<Word> words2) {
double score = getSimilarityImpl(words1, words2);
//(int) (score * 1000000 + 0.5)其实代表保留小数点后六位 ,因为1034234.213强制转换不就是1034234。对于强制转换添加0.5就等于四舍五入
score = (int) (score * 1000000 + 0.5) / (double) 1000000;
return score;
}
/**
* 文本相似度计算 判定方式:余弦相似度,通过计算两个向量的夹角余弦值来评估他们的相似度 余弦夹角原理: 向量a=(x1,y1),向量b=(x2,y2) similarity=a.b/|a|*|b| a.b=x1x2+y1y2
* |a|=根号[(x1)^2+(y1)^2],|b|=根号[(x2)^2+(y2)^2]
*/
public double getSimilarityImpl(List<Word> words1, List<Word> words2) {
// 向每一个Word对象的属性都注入weight(权重)属性值
taggingWeightByFrequency(words1, words2);
//第二步:计算词频
//通过上一步让每个Word对象都有权重值,那么在封装到map中(key是词,value是该词出现的次数(即权重))
Map<String, Float> weightMap1 = getFastSearchMap(words1);
Map<String, Float> weightMap2 = getFastSearchMap(words2);
//将所有词都装入set容器中
Set<Word> words = new HashSet<>();
words.addAll(words1);
words.addAll(words2);
AtomicDouble ab = new AtomicDouble();// a.b
AtomicDouble aa = new AtomicDouble();// |a|的平方
AtomicDouble bb = new AtomicDouble();// |b|的平方
// 第三步:写出词频向量,后进行计算
words.parallelStream().forEach(word -> {
//看同一词在a、b两个集合出现的此次
Float x1 = weightMap1.get(word.getName());
Float x2 = weightMap2.get(word.getName());
if (x1 != null && x2 != null) {
//x1x2
float oneOfTheDimension = x1 * x2;
//+
ab.addAndGet(oneOfTheDimension);
}
if (x1 != null) {
//(x1)^2
float oneOfTheDimension = x1 * x1;
//+
aa.addAndGet(oneOfTheDimension);
}
if (x2 != null) {
//(x2)^2
float oneOfTheDimension = x2 * x2;
//+
bb.addAndGet(oneOfTheDimension);
}
});
//|a| 对aa开方
double aaa = Math.sqrt(aa.doubleValue());
//|b| 对bb开方
double bbb = Math.sqrt(bb.doubleValue());
//使用BigDecimal保证精确计算浮点数
//double aabb = aaa * bbb;
BigDecimal aabb = BigDecimal.valueOf(aaa).multiply(BigDecimal.valueOf(bbb));
//similarity=a.b/|a|*|b|
//divide参数说明:aabb被除数,9表示小数点后保留9位,最后一个表示用标准的四舍五入法
return BigDecimal.valueOf(ab.get()).divide(aabb, 9, BigDecimal.ROUND_HALF_UP).doubleValue();
}
/**
* 向每一个Word对象的属性都注入weight(权重)属性值
*/
protected void taggingWeightByFrequency(List<Word> words1, List<Word> words2) {
if (words1.get(0).getWeight() != null && words2.get(0).getWeight() != null) {
return;
}
//词频统计(key是词,value是该词在这段句子中出现的次数)
Map<String, AtomicInteger> frequency1 = getFrequency(words1);
Map<String, AtomicInteger> frequency2 = getFrequency(words2);
//如果是DEBUG模式输出词频统计信息
// if (LOGGER.isDebugEnabled()) {
// LOGGER.debug("词频统计1:\n{}", getWordsFrequencyString(frequency1));
// LOGGER.debug("词频统计2:\n{}", getWordsFrequencyString(frequency2));
// }
// 标注权重(该词出现的次数)
words1.parallelStream().forEach(word -> word.setWeight(frequency1.get(word.getName()).floatValue()));
words2.parallelStream().forEach(word -> word.setWeight(frequency2.get(word.getName()).floatValue()));
}
/**
* 统计词频
* @return 词频统计图
*/
private Map<String, AtomicInteger> getFrequency(List<Word> words) {
Map<String, AtomicInteger> freq = new HashMap<>();
//这步很帅哦
words.forEach(i -> freq.computeIfAbsent(i.getName(), k -> new AtomicInteger()).incrementAndGet());
return freq;
}
/**
* 输出:词频统计信息
*/
private String getWordsFrequencyString(Map<String, AtomicInteger> frequency) {
StringBuilder str = new StringBuilder();
if (frequency != null && !frequency.isEmpty()) {
AtomicInteger integer = new AtomicInteger();
frequency.entrySet().stream().sorted((a, b) -> b.getValue().get() - a.getValue().get()).forEach(
i -> str.append("\t").append(integer.incrementAndGet()).append("、").append(i.getKey()).append("=")
.append(i.getValue()).append("\n"));
}
str.setLength(str.length() - 1);
return str.toString();
}
/**
* 构造权重快速搜索容器
*/
protected Map<String, Float> getFastSearchMap(List<Word> words) {
if (CollectionUtils.isEmpty(words)) {
return Collections.emptyMap();
}
Map<String, Float> weightMap = new ConcurrentHashMap<>(words.size());
words.parallelStream().forEach(i -> {
if (i.getWeight() != null) {
weightMap.put(i.getName(), i.getWeight());
} else {
LOGGER.error("no word weight info:" + i.getName());
}
});
return weightMap;
}
}
根据以上代码即可计算出给定的两段字符串的相似度,根据自己的需求取大于某个阈值的数据即可。
同义词收集
注意这里的相似度计算是有相似的才会有值,完成不相似的是匹配不到的,假设需要匹配到标签“计算机”的文本,但是我们希望”电脑“这个词也能匹配到,我们提供的是一个界面操作让用户自己可以手动维护一个同义词库,每次匹配”计算机“这个标签的时候带着同义词匹配,如果有同义词能匹配到那么就自动带出这个标签,这样系统也会越来越”智能“,随着匹配的次数增多,也会越来越”聪明“。
新闻推送
根据新闻的文本大概的可以算出与哪些标签相关,再根据标签与企业的关联关系,就可以获取到企业与新闻的关联关系,这样就直接把新闻推送给相关的企业即可