基於語義連貫性實現主題挖掘和分類

約定一下文中使用的一些詞的含義:

  • 文章:一般來說,一篇文章具有一個標題、一個或多個段落組成,其他的我們暫時不考慮。
  • 段落:一篇文章可以根據縮進(有些可能不存在縮進)或回車換行,將文章分成多個段,而每段是由數個句子組成。
  • 片段:片段是由一個或者多個段落組成,但是片段最多不能大於一篇文章的全部段落數,我們限定在一篇文章之內。


基礎概述


對於給定的一篇文章,它到底在圍繞哪一個主題(或哪幾個主題)進行描述,我們在實際生活中經常會遇到。我們在讀小學的時候,老師教我們給文章分段並歸納段落大意,其實,這就是在考慮基於文章段落之間的關係和語義內容對整個文章進行劃分,得到的結果是確定某一些連續的段落屬於某一個主題。如果一篇文章段落很多,而且根據標題可以確定具有一個主題A,那麼這篇文章仍然可以在主題A的範疇內,繼續提煉出多個子主題集合{A1, A2, ..., An},也就是說文章仍然是可分的。

上面講述的是給定一篇文章,根據文章內容抽取出具有多個主題的段落,你可能很容易想到,這就是數據挖掘中的聚類分析:把具有相同特徵(主題)的一些連續的段落聚集成一個類,而不去限定類別是什麼,可能你根本也不知道能劃分成哪些“基於一定主題”的文章片段,也可能不同的文章片段之間出現重疊的段落。在實際中,可能存在這樣的應用場景。

但是,在實際中更多的是,基於給定一個關鍵字詞典,基於詞典來對一篇文章進行主題劃分:抽取出文章的某些段落作爲一個片段,這個片段當當且僅當這一組段落在講述某一個主題時才成立。一個主題通常可以由一個詞向量來表達,例如主題“體育運動”可以由詞向量(足球, 籃球, 排球 ,網球 , 跑步, NBA)來表達,文章的某些段落中在講述詞向量中詞項描述的內容時,基本可以認爲在講述給定的主題。詞向量是否足夠豐富地表達主題,這就要看你應用需求和你的詞典中詞項的分佈情況。我們分析一下:存在“主題-詞向量”詞典,也就是說在分析文章的時候,它的類標籤只限定於給定的主題詞典,實際上這是分類,基於文章內容對文章的多個片段進行分類,而待分類的文章片段需要基於某些啓發式信息(或經過預處理的元數據)進行抽取。

我們討論下面兩個問題:

  • 第一個:獲取主題信息困難

你在看一篇字數很長的文章的時,如果你是想快速獲取到你想知道的某一個主題內容,那你必須讀過整篇文章之後,才知道哪些段落在講述這個主題。很有可能一篇文章有50多段,但是描述你想要的主題的只有其中的2~3段,這是你要花費時間瀏覽很多不相關的信息。如果這篇文章正是你展現給用戶的信息,那麼這種信息太沒有價值了(雖然某些段落可能是精華,但對用戶來說不次於垃圾信息),用戶體驗相當的差:用戶可能在瀏覽的過程中對主題的關注度已經分散了,可能因此而離開你的提供數據的網站——用戶流失。

  • 第二個:主題相關信息不連貫
雖然我從文章中抽取出了這些包含指定主題詞的段落,但是這些段落需要一定的連貫性,才能讓用戶在閱讀的時候語義更加流暢,否則用戶感覺自己在閱讀一篇沒有完整性的文章片段,而且語義的殘缺造成了無法正確獲取主題的實際語義。所以,我們要考慮語義連貫性——我找了一個有關語義連貫性的定義及說明,引用如下:

書面表達中句與句之間的組合與銜接問題,保持語言連貫,需要兼顧話題(有共同的話題)、語序(合理的語序)和語言的運用(語言的銜接與呼應)三個方面還要注意語境、句式的協調一致。
1、話題統一
指組成段落的句子間,或是組成複句的分句間,有緊密聯繫,圍繞着一箇中心,集中表現一個事實、場景、或思想觀點,無關的話不摻雜在一起。 
(1)陳述對象一致。
(2)觀點和材料的統一。
2、句式前後一致(照應)
能收到形勢整齊,音節和諧、氣勢貫通的修辭效果。
(1)句式要協調一致。指一組句子中幾個句子的句式要大體相同,讀來才琅琅上口,語氣連貫或相互對應。
(2)要有必要的過渡、交待、銜接、呼應。恰當的使用關聯詞語及比照式的詞語。
3、合乎事理、語境
(1)意思表達要合乎客觀事例,否則,上下句在事理上出現了“裂痕”,銜接不上。
(2)表達要合乎語境。對於寫景的複句和語段,要注意語境因素,要分析景物、情調、寫法的特點。景物有遠近動靜不同;色彩有鮮明、暗淡之分,氣氛有熱烈悽清;視角有高低俯仰之異;態度或褒或貶。要保持和諧一致。
4、語序合理
(1)時間順序。按陳述內容的時間先後作爲排列順序。(多用於記敘文)
(2)空間順序。按照空間的上下、左右、外內等順序。(多用於描寫、說明性文字)
(3)邏輯順序。一是人們對客觀事物的認識順序,如現象本質、個別一般、淺深、感性理性等;二是客觀事物固有的內部邏輯,因果、輕重。

爲了簡化問題,我們考慮語義連貫性的如下幾個要素:

  • 兼顧話題: 上面已經說過,通過主題及其主題相關詞向量來表達,可以考慮主題相關詞向量中的詞項的出現頻率,對片段的邊緣段落加權並向兩側擴展段落;
  • 保持語序: 雖然包含主題詞片段中某些段落之間不連續,例如段落A和C被抽取出來,但是B中不包含主題詞,但是可以根據段落B的一些特徵(如長度、包含圖片)來選擇,將B加入到這個片段的段落組中,使片段中個段落之間語義連貫。


基本思想


我們要實現的基於語義連貫性,對文章的片段進行挖掘(對選擇的段落進行組合),主要是基於如下幾點:

  • 建立自己的領域詞典D:用來描述主題的詞條(詞向量)
  • 對文章進行分詞,獲取一篇文章中存在的且出現在詞典中的關鍵詞集合S
  • 根據關鍵詞集合S中的每一個詞,對整篇文章中的各個段落(有序)進行匹配,不考慮詞頻(TF)影響
  • 設定語義連貫性標準:如果按照文章段落順序(序號),對序號i和序號j,若j-i<maxParagraphGap,則認爲i和j兩段連續(maxParagraphGap可以調整)
  • 語義連貫性擴展:如果一篇文章段落序號序列{0, 1, 2, ... , x, x+1, x+2, ..., y, y+1, ..., N},如果x-0<maxParagraphGap且N-y<maxParagraphGap,則選擇整篇文章作爲一個段落,亦即,整篇文章都在描述一個主題
  • 基於關鍵詞集合S中的每一個關鍵詞,在一篇文章抽取一組片段F={f1, f2, ... , fn},這組片段的選擇標準:對F中的片段對應的每個段落集合{P1, P2, ... , Pm},統計段落數爲n,一篇文章總段落數N,計算抽取出來的所有段落的覆蓋率,可以設定一個闕值Coverage=n/N
當然,上述基本思想可以根據實際文章內容的特點進行個性化定製,具有很大的改進空間,優化抽取段落以及使按主題分類更加準確。


程序實現


實現上述基本思想的代碼,程序中已經做了詳細的註釋,不再累述。代碼如下所示:
package org.shirdrn.ie.travel;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.regex.Pattern;

import org.apache.log4j.Logger;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.analysis.tokenattributes.CharTermAttributeImpl;

import com.chenlb.mmseg4j.Dictionary;
import com.chenlb.mmseg4j.analysis.ComplexAnalyzer;
import com.mongodb.BasicDBObject;
import com.mongodb.DBCollection;
import com.mongodb.DBCursor;
import com.mongodb.DBObject;
import org.shirdrn.common.DBConfig;

/**
 * Article analysis tool.
 * 
 * @author shirdrn
 * @date 2011-12-13
 */
public class ArticleAnalyzeTool {

	private static final Logger LOG = Logger.getLogger(ArticleAnalyzeTool.class);
	private Analyzer analyzer;
	private String dictPath;
	private String wordsPath;
	/** 自定義關鍵字詞典:通常是根據需要進行定向整理的 */
	private Set<String> wordSet = new HashSet<String>();
	private DBConfig articleConfig;
	private MongodbAccesser accesser;
	/** 最小段落內容長度限制 */
	private int globalMinParagraphLength = 3;
	/** 最大段落間隔數限制 */
	private int globalMaxParagraphGap = 3;
	/** 截取片段中的段落數,必須大於1 */
	private int globalMinFragmentParagraphsCount = 3;
	/** 截取最大片段數限制,必須大於1 */
	private int globalMinFragmentSize = 4;
	/** 一個word在一篇文章的片段集合,在整篇文章中的覆蓋率:如果大於該值,則選擇整篇文章作爲一個片段,否則分別取出多個片段 */
	private double globalCoverage = 0.75;

	public ArticleAnalyzeTool(DBConfig articleConfig) {
		this.articleConfig = articleConfig;
		accesser = new MongodbAccesser(articleConfig);
	}

	/**
	 * 初始化:初始化Lucene分析器,並加載詞典
	 */
	public void initialize() {
		analyzer = new ComplexAnalyzer(Dictionary.getInstance(dictPath));
		try {
			BufferedReader reader = new BufferedReader(new FileReader(this.wordsPath));
			String line = null;
			while ((line = reader.readLine()) != null) {
				if (!line.isEmpty()) {
					wordSet.add(line.trim());
				}
			}
			reader.close();
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

	/**
	 * 分析程序入口主驅動方法
	 * 
	 * @param conditions
	 * @param config
	 */
	public void runWork(Map<String, Object> conditions, DBConfig config) {
		MongodbAccesser newAccesser = new MongodbAccesser(config);
		DBCollection newCollection = newAccesser.getDBCollection(config.getCollectionName());

		DBCollection collection = accesser.getDBCollection(articleConfig.getCollectionName());
		DBObject q = new BasicDBObject();
		if (conditions != null && !conditions.isEmpty()) {
			q = new BasicDBObject(conditions);
		}
		DBCursor cursor = collection.find(q);
		StringBuffer words = new StringBuffer();
		while (cursor.hasNext()) {
			try {
				DBObject result = cursor.next();
				ParagraphsAnalyzer pa = new ParagraphsAnalyzer(result);
				List<LinkedHashMap<String, Object>> all = pa.analyze();
				if (!all.isEmpty()) {
					for (LinkedHashMap<String, Object> m : all) {
						newCollection.insert(new BasicDBObject(m));
						LOG.info("Insert: " + m.get("articleId") + ", " + m.get("word") + ", " + m.get("fragmentSize") + "/" + m.get("paragraphCount") + ", " + m.get("selected"));
					}
				}
				words.delete(0, words.length());
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
		cursor.close();
	}

	public void setWordsPath(String wordsPath) {
		this.wordsPath = wordsPath;
	}

	public void setDictPath(String dictPath) {
		this.dictPath = dictPath;
	}

	public void setGlobalMinParagraphLength(int globalMinParagraphLength) {
		this.globalMinParagraphLength = globalMinParagraphLength;
	}

	public void setGlobalMaxParagraphGap(int globalMaxParagraphGap) {
		this.globalMaxParagraphGap = globalMaxParagraphGap;
	}

	public void setGlobalMinFragmentParagraphsCount(int globalMinFragmentParagraphsCount) {
		this.globalMinFragmentParagraphsCount = globalMinFragmentParagraphsCount;
	}

	public void setGlobalMinFragmentSize(int globalMinFragmentSize) {
		this.globalMinFragmentSize = globalMinFragmentSize;
	}

	public void setGlobalCoverage(double globalCoverage) {
		this.globalCoverage = globalCoverage;
	}

	class ParagraphsAnalyzer {
		/** 一條記錄 */
		private DBObject result;

		// 下面集合中涉及到Term的,是基於給定的數據(如手工整理的景點或目的地)過濾得到的
		/** LinkedHashMap<文章段落序號, 文章段落內容> */
		private LinkedHashMap<Integer, String> paragraphMap = new LinkedHashMap<Integer, String>();
		/** LinkedHashMap<文章段落序號, 段落分詞後Term組> */
		private LinkedHashMap<Integer, Set<String>> paragraphTermMap = new LinkedHashMap<Integer, Set<String>>();
		/** 文章全部段落分詞後得到的Term及其TF集合 */
		private Map<String, IntCounter> allTermsCountMap = new HashMap<String, IntCounter>();
		/** Map<文章段落編號, 段落長度> */
		private Map<Integer, Integer> paragraphLenMap = new HashMap<Integer, Integer>();

		// 一些限制參數配置,考慮局部可以動態調整,如果外部進行了全局設置,可以根據局部來動態調整
		/** 最小段落內容長度限制 */
		private int minParagraphLength = 3;
		/** 最大段落間隔數限制:必須大於2 */
		private int maxParagraphGap = 2;
		/** 截取片段中的段落數,必須大於1 */
		private int minFragmentParagraphsCount = 3;
		/** 截取最大片段數限制,必須大於1 */
		private int minFragmentSize = 5;
		/** 一個word在一篇文章的片段集合,在整篇文章中的覆蓋率:如果大於該值,則選擇整篇文章作爲一個片段,否則分別取出多個片段 */
		private double coverage = 0.75;
		
		/** 文章內容長度 */
		private int contentLength = 0;
		/** 計劃平均片段長度,用於分割整篇文章 */
		private int planningFragmentAverageLength = 600;
		

		public ParagraphsAnalyzer(DBObject result) {
			super();
			this.result = result;
			this.minParagraphLength = globalMinParagraphLength;
			this.maxParagraphGap = globalMaxParagraphGap;
			this.minFragmentParagraphsCount = globalMinFragmentParagraphsCount;
			this.minFragmentSize = globalMinFragmentSize;
			this.coverage = globalCoverage;
			beforeAnalysis();
		}

		private void beforeAnalysis() {
			// TODO
			// 這裏可做一些預分析工作:根據一篇文章的整體段落情況,確定全局適應的參數,如minParagraphLength、maxParagraphGap
			// 文章段落的連貫性可以考慮(maxParagraphGap),如果兩個段落之間文字數量小於設定的闕值則認爲是連貫的,可以直接作爲一個相關的片段抽取出來

			// TODO
			// 可以根據預分析結果,適當調整局部minParagraphLength、maxParagraphGap、minFragmentSize、coverage

		}

		public List<LinkedHashMap<String, Object>> analyze() {
			String content = (String) result.get("content");
			String[] paragraphs = content.split("\n+");
			int paragraphIdCounter = 0;
			for (int i = 0; i < paragraphs.length; i++) {
				if (!paragraphs[i].isEmpty()) {
					int len = paragraphs[i].trim().length();
					contentLength += len;
					paragraphLenMap.put(paragraphIdCounter, len);
					paragraphMap.put(paragraphIdCounter, paragraphs[i].trim());
					if (paragraphs[i].trim().length() > minParagraphLength) {
						analyzeParagraph(paragraphIdCounter, paragraphs[i].trim());
					}
					paragraphIdCounter++;
				}
			}

			// 每一個段落分別處理完成後,綜合個段落數據信息,進行綜合分析
			// 此時,可用的數據集合如下:
			// 1. paragraphMap 整篇文章各個段落:Map<文章段落序號, 文章段落內容>
			// 2. allTermsCountMap 文章全部段落分詞後得到的Term及其TF集合
			// 3. paragraphTermMap 整篇文章各個段落:Map<文章段落序號, 段落分詞後Term組>
			// 4. paragraphLenMap 整篇文章各個段落:Map<文章段落序號, 段落長度>

			Map<String, Object> raw = compute(paragraphs.length);
			// 處理抽取出來的片段,組織好後存儲到數據庫
			List<LinkedHashMap<String, Object>> forStore = new ArrayList<LinkedHashMap<String, Object>>();
			List<Fragment> fragmentList = (List<Fragment>) raw.remove("fragment");
			if (fragmentList != null) {
				for (Fragment frag : fragmentList) {
					LinkedHashMap<String, Object> record = new LinkedHashMap<String, Object>();

					record.put("articleId", result.get("_id").toString());
					record.put("title", result.get("title"));
					record.put("url", result.get("url"));
					record.put("spiderName", result.get("spiderName"));
					record.put("publishDate", result.get("publishDate"));
					record.put("word", frag.word);

					StringBuffer selectedIdBuffer = new StringBuffer();
					for (int paragraphId : frag.paragraphIdList) {
						selectedIdBuffer.append(paragraphId).append(" ");
					}
					record.put("paragraphCount", raw.get("paragraphCount"));
					record.put("fragmentSize", frag.end - frag.start + 1);
					record.put("selectedCount", frag.paragraphIdList.size());
					record.put("selected", selectedIdBuffer.toString().trim());

					// 將連續的段落內容拼接在一起
					LinkedHashMap<Integer, String> selected = new LinkedHashMap<Integer, String>();
					int start = frag.start;
					int end = frag.end;
					for (int i = start; i <= end; i++) {
						selected.put(i, paragraphMap.get(i));
					}
					record.put("fragment", selected);

					record.put("paragraphs", raw.get("paragraphs"));

					// 最終需要存儲的記錄集合
					forStore.add(record);
				}
			}
			return forStore;
		}

		/**
		 * 分析文章的一個段落:爲分析整篇文章,準備各個段落相關的元數據
		 * 
		 * @param paragraphId
		 * @param paragraph
		 */
		private void analyzeParagraph(int paragraphId, String paragraph) {
			// 一個段落中分詞後得到的Term的TF統計,暫時沒有用到
			Map<String, IntCounter> counterMap = new HashMap<String, IntCounter>();
			Set<String> set = new HashSet<String>();
			// 對一個段落的文本內容進行分詞處理
			Reader reader = new StringReader(paragraph.trim());
			TokenStream ts = analyzer.tokenStream("", reader);
			ts.addAttribute(CharTermAttribute.class);
			try {
				while (ts.incrementToken()) {
					CharTermAttributeImpl attr = (CharTermAttributeImpl) ts.getAttribute(CharTermAttribute.class);
					String word = attr.toString().trim();
					if (word.length() > 1 && wordSet.contains(word)) {
						if (counterMap.containsKey(word)) {
							++counterMap.get(word).value;
						} else {
							counterMap.put(word, new IntCounter(1));
						}
						set.add(word);

						// 加入到全局Term集合allTermsCountMap中
						if (allTermsCountMap.containsKey(word)) {
							++allTermsCountMap.get(word).value;
						} else {
							allTermsCountMap.put(word, new IntCounter(1));
						}
					}
				}
				paragraphTermMap.put(paragraphId, set);
			} catch (IOException e) {
				e.printStackTrace();
			}
		}

		private Optimizer optimizer;

		private Map<String, Object> compute(int paragraphCount) {
			// 返回一篇文章中,每個word對應段落的集合
			Map<String, Object> result = new HashMap<String, Object>();
			result.put("paragraphs", paragraphMap);
			result.put("paragraphCount", paragraphCount);
			// 這個列表中的每個Fragment對應着最終需要存儲的記錄
			List<Fragment> fragmentList = new ArrayList<Fragment>();
			// 如果需要排序,可以在這裏按照allTermsCountMap的Term的TF降序排序

			Iterator<Entry<String, IntCounter>> iter = allTermsCountMap.entrySet().iterator();
			while (iter.hasNext()) {
				Map.Entry<String, IntCounter> entry = iter.next();
				// 對每個Term,遍歷整篇文章,對Term的分佈進行分析
				String word = entry.getKey();
				IntCounter freq = entry.getValue();
				// 如果需要,可以進行剪枝,降低無用的計算(如根據TF,TF在一篇文章太低,可以直接過濾掉)
				// 這裏剪枝條件設置爲TF>1
				if (freq.value > 1) {
					// 獲取到一個word在一篇文章哪些段落中出現過
					List<Integer> paragraphIdList = choose(word, paragraphCount);
					// 提取一個word相關的段落集合,應該考慮如下因素:
					// 1. 一篇文章的段落總數
					// 2. 一個word出現的各個段落之間間隔數
					// 3. 獲取多少個段落能夠以word爲中心
					List<Integer> list = new ArrayList<Integer>();
					List<Fragment> temp = new ArrayList<Fragment>();
					if (paragraphIdList.size() >= Math.max(2, minFragmentParagraphsCount)) {
						// 包含段落太少的文章,直接過濾掉

						// 根據包含word的段落序號列表,計算一篇文章中有多個片段的集合
						int previous = paragraphIdList.get(0);
						list.add(previous);
						for (int i = 1; i < paragraphIdList.size(); i++) {
							int current = paragraphIdList.get(i);
							if (current - previous <= maxParagraphGap) {
								list.add(current);
							} else {
								makeFragment(word, list, temp);
								list = new ArrayList<Integer>();
								list.add(current);
							}
							previous = current;
						}
						if (!list.isEmpty()) {
							makeFragment(word, list, temp);
						}
					}

					// 一個關鍵詞,在一篇文章中,根據分析後的結果選擇抽取的片段集合
					if (!temp.isEmpty()) {
						// 這裏對多個片段Fragment的集合進行綜合分析、優化處理
						optimizer = new CoverageOptimizer();
						temp = optimizer.optimize(word, temp, paragraphCount);
						if(temp!=null && !temp.isEmpty()) {
							fragmentList.addAll(temp);
						}
					}
				}
			}
			// 返回抽取到的片段集合
			// 在存儲到數據庫之前,先要對其進行拆分、組合、優化
			result.put("fragment", fragmentList);
			return result;
		}

		private void makeFragment(String word, List<Integer> list, List<Fragment> temp) {
			Fragment frag = new Fragment(word, list);
			frag.start = list.get(0);
			frag.end = list.get(list.size() - 1);
			temp.add(frag);
		}

		/**
		 * 處理一個Term在整篇文章中的分佈情況
		 * 
		 * @param word
		 * @param paragraphCount
		 * @return
		 */
		private List<Integer> choose(String word, int paragraphCount) {
			// 遍歷每一個段落的Term集合paragraphTermMap,獲取包含該word的段落序號集合
			List<Integer> paragraphIdList = new ArrayList<Integer>();
			for (Entry<Integer, Set<String>> entry : paragraphTermMap.entrySet()) {
				if (entry.getValue().contains(word)) {
					paragraphIdList.add(entry.getKey());
				}
			}
			return paragraphIdList;
		}

		class CoverageOptimizer implements Optimizer {

			@Override
			public List<Fragment> optimize(String word, List<Fragment> temp, int paragraphCount) {
				// 考慮一個word在文章中截取的片段,在整篇文章中的覆蓋情況
				List<Fragment> selectedFragmentList = null;
				double sum = 0.0;
				for (Fragment f : temp) {
					sum += (f.end - f.start + 1); // 累加片段的長度
				}
				double rate = sum / (double) paragraphCount;
				// 滿足覆蓋條件
				// TODO 可以基於覆蓋條件,再考慮抽取段落在整篇文章中的分佈,如果分佈均勻,酌情降低覆蓋率以選取整篇文章爲一個段落
				if (rate >= coverage) {
					selectedFragmentList = updateFragment(word, temp, paragraphCount);
				} else {
					// 不滿足覆蓋率條件,看一下全部片段的段落集合在整篇文章中的分佈情況
					// 考慮分佈:將整篇文章分成幾個連續片段的集合,看我們計算關鍵詞的得到的片段集合,是否與連續片段集合發生重疊
					Fragment first = temp.get(0);
					Fragment last = temp.get(temp.size()-1);
					// 這個條件有點太弱,如果一篇文章中段落很多,開始部分一段,結尾部分一段,結果就會取整篇文章
					// TODO 所以還要考慮覆蓋率,降低覆蓋率的條件,但是提高文章邊界價差限制,覆蓋率初步定爲文章段落數量的一半						
					if(rate >= 0.5 && first.paragraphIdList.get(0) - 0 < maxParagraphGap 
							&& paragraphCount - last.paragraphIdList.get(last.paragraphIdList.size() - 1) < maxParagraphGap) {
						return updateFragment(word, temp, paragraphCount);
					}
					
					// 用於優化:如果段落很少,很有可能這篇文章出現關鍵詞段落數覆蓋率很低,但是這篇文章確實是講該關鍵詞表達的主題
					// TODO 是否可以考慮做一個分段函數y = f(x, y, z), x:片段數 y:文章段落數 z:文章文本總長度
					if(temp.size()==1 && paragraphCount<10) {
						return updateFragment(word, temp, paragraphCount);
					}
					
					// TODO 如果一個片段,片段起始段落距離文章首段距離大於maxParagraphGap,可以考慮計算一下這段間隔的文字數量來進行優化
					// 文章末尾段落也可以類似考慮,暫時不做
					// do something
					
					// 需要對各個片段進行篩選
					for (Iterator<Fragment> iter = temp.iterator(); iter.hasNext();) {
						// 片段包含的段落太少
						if (iter.next().paragraphIdList.size() < minFragmentSize) {
							iter.remove();
						}
					}
					return temp;
				}
				// TODO 可以考慮文章段落中出現的多組圖片,導致文章段落銜接隔斷問題
				return selectedFragmentList;
			}

			private List<Fragment> updateFragment(String word, List<Fragment> temp, int paragraphCount) {
				List<Fragment> selectedFragmentList = new ArrayList<Fragment>();
				List<Integer> list = new ArrayList<Integer>();
				for (Fragment f : temp) {
					list.addAll(f.paragraphIdList);
				}
				Fragment f = new Fragment(word, list);
				f.start = 0;
				f.end = paragraphCount-1;
				selectedFragmentList.add(f);
				return selectedFragmentList;
			}
		}
		
		class DistributionOptimizer implements Optimizer {

			@Override
			public List<Fragment> optimize(String word, List<Fragment> temp, int paragraphCount) {
				List<Fragment> selectedFragmentList = new ArrayList<Fragment>();
				// write your logic
				return selectedFragmentList;
			}
		}
	}

	/**
	 * 片段選擇優化器
	 * 
	 * @author shirdrn
	 * @date 2011-12-19
	 */
	interface Optimizer {
		public List<Fragment> optimize(String word, List<Fragment> temp, int paragraphCount);
	}

	/**
	 * 計數器:爲了對Map中數據對象計數方便
	 * 
	 * @author shirdrn
	 * @date 2011-12-15
	 */
	class IntCounter {
		Integer value;

		public IntCounter() {
			this(0);
		}

		public IntCounter(Integer value) {
			super();
			this.value = value;
		}

		@Override
		public String toString() {
			return value == null ? "0" : value.toString();
		}
	}

	/**
	 * 文章的一個片段:由一個或多個段落組成
	 * 
	 * @author shirdrn
	 * @date 2011-12-15
	 */
	class Fragment {
		/** 關鍵詞 */
		String word;
		/** 經過分析後,最終確定的片段截取起始段落序號 */
		int start;
		/** 經過分析後,最終確定的片段截取終止段落序號 */
		int end;
		/** 出現word的段落的序號列表 */
		List<Integer> paragraphIdList;

		public Fragment() {
			super();
		}

		public Fragment(String word, List<Integer> paragraphIdList) {
			super();
			this.word = word;
			this.paragraphIdList = paragraphIdList;
		}

		@Override
		public String toString() {
			return "[word=" + word + ", start=" + start + ", end=" + end + ", paragraphIdList=" + paragraphIdList + "]";
		}
	}
}


上述代碼中,相關參數的默認設定,如下所示:
  • minParagraphLength=3, 最小段落長度限制,爲保證文章連貫性,滿足小於該值的可有可無(可以保留)
  • maxParagraphGap=2,最大段落間隔限制
  • minFragmentSize=5,最小片段長度限制,如果某個關鍵詞在一篇文章中抽取的片段中段落時小於該值,被過濾掉
  • minFragmentParagraphsCount=5,做小片段包含段落數限制
  • word長度限制,大於1
  • coverage=0.75,基於某個詞word抽取的片段集合在整篇文章中的覆蓋率
你可以根據需要進行擴展和修改,選擇設置最適合你的參數值。
測試用例,代碼如下所示:
package org.shirdrn.ie.travel;

import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

import junit.framework.TestCase;

import org.shirdrn.common.DBConfig;

public class TestArticleAnalyzeTool extends TestCase {

	String indexPath;
	ArticleAnalyzeTool tool;
	DBConfig dictLoaderConfig;
	DBConfig articleConfig;
	
	@Override
	protected void setUp() throws Exception {
		articleConfig = new DBConfig("192.168.0.184", 27017, "page", "Article");
		tool = new ArticleAnalyzeTool(articleConfig);
	}
	
	public void testArticlesAnalyzeTool() {
		if(true) {
			tool.setWordsPath("E:\\words-attractions.dic");
			tool.setDictPath("E:\\Develop\\eclipse-jee-helios-win32\\workspace\\EasyTool\\dict");
			tool.initialize();
			tool.setGlobalMaxParagraphGap(5);
			tool.setGlobalMinFragmentParagraphsCount(3);
			tool.setGlobalMinParagraphLength(3);
			tool.setGlobalMinFragmentSize(3);
			tool.setGlobalCoverage(0.65);
			
			Map<String, Object> spiderToCollection = new HashMap<String, Object>();
			spiderToCollection.put("mafengwoSpider", "mafengwo");
			spiderToCollection.put("go2euSpider", "go2eu");
			spiderToCollection.put("daodaoSpider", "daodao");
			spiderToCollection.put("lotourSpider", "lotour");
			spiderToCollection.put("17uSpider", "17u");
			spiderToCollection.put("lvpingSpider", "lvping");
			spiderToCollection.put("sinaSpider", "sina");
			spiderToCollection.put("sohuSpider", "sohu");
			for (Iterator<Map.Entry<String, Object>> iterator = spiderToCollection.entrySet().iterator(); iterator.hasNext();) {
				Map.Entry<String, Object> entry = iterator.next();
				Map<String, Object> conditions = new HashMap<String, Object>();
				conditions.put("spiderName", entry.getKey());
				DBConfig config = new DBConfig("192.168.0.184", 27017, "fragment", (String) entry.getValue());
				tool.runWork(conditions, config);
			}
		}
	}
}


上述測試用例中,主題詞詞典可以根據你的需要選擇,我的都存儲到了E:\\words-attractions.dic中,而E:\\Develop\\eclipse-jee-helios-win32\\workspace\\EasyTool\\dict是Lucene用於分詞的詞典。
測試代碼執行,結果示例如下所示:
{
   "_id": ObjectId("4eeb3ae7ca251f31c5b10d79"),
   "articleId": "4ed31f1df776481ed002e71d",
   "title": "2010杭州九溪行",
   "url": "http: \/\/www.mafengwo.cn\/i\/623758.html",
   "spiderName": "mafengwoSpider",
   "publishDate": "2010-05-15 13: 19: 09",
   "word": "西湖",
   "paragraphCount": 14,
   "fragmentSize": 7,
   "selectedCount": 4,
   "selected": "7 9 10 13",
   "fragment": {
     "7": "       過了九溪煙樹,向着龍井村進發。一路上看到的全是茶園。西湖龍井也是有品牌的,這裏都是六和塔牌的。(PS: 我怎麼總想起馬季那個宇宙牌香菸呢。。。) [...]",
     "8": "<img src ='http: \/\/file2.mafengwo.net\/M00\/3D\/F0\/wKgBm04VGUDpGZVtAANCFqD0HjM39.groupinfo.w600.jpeg' title ='杭州旅遊攻略圖片' alt ='杭州旅遊攻略圖片' width ='600' [...]",
     "9": "       和一位茶農阿婆一路聊着天就走到了龍井村。都說杭州新西湖十景裏有個“龍井問茶”。那既然來到了龍井村,就不能白白錯過啊。。。 [...]",
     "10": "       出了龍井村,如果腳力好的話,建議可以徒步下山。林間的公路在夕陽的印襯下更顯靜謐的味道。一路上會穿過另一個新西湖十景——“滿隴桂雨”。如果是秋天的話,一路上都可以聞到桂花的香味。很是舒服啊~~ [...]",
     "11": "<img src ='http: \/\/file2.mafengwo.net\/M00\/3D\/F1\/wKgBm04VGUDw9xs8AAMnNG6MHkM33.groupinfo.w600.jpeg' title ='杭州圖片' alt ='杭州圖片' width ='600' height = [...]",
     "12": "       等到我一步一個賞味的從滿隴桂雨出來,我這天的九溪行也就可以畫上句號了。",
     "13": "       提起杭州,很多人反映出的只有西湖,其實在西湖的周邊還有很多好玩的地方。比如九溪。如果時間允許的話,不妨到周邊的景點走走看看吧。杭州,真的不是隻有西湖哦~~ [...]" 
  },
   "paragraphs": {
     "0": "說好的一起去杭州聚會,結果被先後放了鴿子。5個人的聚會,在4月28日的晚上瞬間變成我獨獨的一個人。糾結了一個晚上要不要取消這次旅行,還是沒想好。29號早上一個朋友告訴讓我興奮的消息——她陪我去杭州。然而好景不長,還沒等我從興奮的喜悅中出來,她告訴我5月1日去杭州的票全部賣光,連站票都沒了。。。好吧, [...]",
     "1": "      繼續糾結了一個晚上,左思右想去還是不去。可是可是,這次旅行是很早之前自己承諾給自己的。別人放了我鴿子,我總不能自己對自己也不守信用吧?往返車票已經買好,酒店也已經訂好,我還猶豫什麼呢?不就是一個人上路麼。其實一個人也有一個人的好,可以遵從自己的心,想走到哪裏就走到哪裏,累了可以休息,休息 [...]",
     "2": "       我終於來到了嚮往已久的杭州九溪。乘坐Y5路車到九溪站下車。話說玩九溪有兩種走法,一種是從下往上走(例如我),另一種是從上往下走(坐Y3路車到楊梅嶺下車)。從實際情況來看我更傾向第一種。九溪又叫九溪十八澗,聽名字就知道是個有山有水的地方,一路上左邊是茶園右邊是溪水,好不愜意。有些小朋友幹 [...]",
     "3": "<img src ='http: \/\/file4.mafengwo.net\/M00\/3D\/EF\/wKgBm04VGT-le47JAAIdz-AMEpU09.groupinfo.w600.jpeg' title ='杭州自助遊圖片' alt ='杭州自助遊圖片' width ='600' he [...]",
     "4": "更有些小情侶互相依偎着坐在溪水邊談情蜜意。這麼個好地方,夏天來估計更舒服。呵呵~~",
     "5": "       再往前走就到了九溪的標誌景點——九溪煙樹。果然景如其名,這樹像畫兒似的非常漂亮。",
     "6": "<img src ='http: \/\/file4.mafengwo.net\/M00\/3D\/EF\/wKgBm04VGUC7WuR6AAM8XvRaDaQ82.groupinfo.w600.jpeg' title ='杭州景點圖片' alt ='杭州景點圖片' width ='600' heig [...]",
     "7": "       過了九溪煙樹,向着龍井村進發。一路上看到的全是茶園。西湖龍井也是有品牌的,這裏都是六和塔牌的。(PS: 我怎麼總想起馬季那個宇宙牌香菸呢。。。) [...]",
     "8": "<img src ='http: \/\/file2.mafengwo.net\/M00\/3D\/F0\/wKgBm04VGUDpGZVtAANCFqD0HjM39.groupinfo.w600.jpeg' title ='杭州旅遊攻略圖片' alt ='杭州旅遊攻略圖片' width ='600' [...]",
     "9": "       和一位茶農阿婆一路聊着天就走到了龍井村。都說杭州新西湖十景裏有個“龍井問茶”。那既然來到了龍井村,就不能白白錯過啊。。。 [...]",
     "10": "       出了龍井村,如果腳力好的話,建議可以徒步下山。林間的公路在夕陽的印襯下更顯靜謐的味道。一路上會穿過另一個新西湖十景——“滿隴桂雨”。如果是秋天的話,一路上都可以聞到桂花的香味。很是舒服啊~~ [...]",
     "11": "<img src ='http: \/\/file2.mafengwo.net\/M00\/3D\/F1\/wKgBm04VGUDw9xs8AAMnNG6MHkM33.groupinfo.w600.jpeg' title ='杭州圖片' alt ='杭州圖片' width ='600' height = [...]",
     "12": "       等到我一步一個賞味的從滿隴桂雨出來,我這天的九溪行也就可以畫上句號了。",
     "13": "       提起杭州,很多人反映出的只有西湖,其實在西湖的周邊還有很多好玩的地方。比如九溪。如果時間允許的話,不妨到周邊的景點走走看看吧。杭州,真的不是隻有西湖哦~~ [...]" 
  } 
}
上述結果中,個字段的含義如下:
  • word是出現在詞典中,並且在文章中出現的關鍵詞
  • paragraphCount是組成一篇文章的段落數
  • selected是文章中抽取出來包含關鍵詞word的段落的段落序號列表
  • selectedCount是selected中段落序號的個數
  • fragmentSize是selected字段中指示的段落號“起始~終止”之間一共包含的段落數,例如selected=3 5 8 12,則fragmentSize=12-3+1=10
  • fragment中是抽取出來的片段內容,word是片段的主題內容,這正是我們實際想要的內容
  • paragraphs是包含段落序號的文章的段落集合
根據上述測試結果,抽取的段落還是可以說明給定關鍵詞表達的主題的。不過,抽取的主主題還是依賴於你所選定的關鍵詞詞典,以及你所使用的選擇片段段落的標準。我們在上面,沒有真正考慮一個關鍵詞,在一篇文章中的分佈情況,而只是簡單地根據出現某個關鍵詞的段落數與文章全部段落數的一個比值來進行選擇,而實際考慮選擇的片段中的一組段落在文章中的分佈情況,更能確定一篇文章的主題(如果你的需求是確定文章講的某一個主題)。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章