2011年2月11日

imdict-chinese-analyzer .NET转写版

中文切词领域,中科院开发的 ICTCLAS 占有重要一席,号称是世界上最好的中文分词系统。ICTCLAS 初期曾发布过一个免费版本(C++),采用“自然语言处理开放资源许可证”公开。后来走向商业开发道路,最新版本是 ICTCLAS 2010,提供有 C++, Java, C# 等多种版本可供购买。

从 ICTCLAS Free 版有一些衍生版本:ictclas4j 是张新波(sinboy)移植的 Java 版本,SharpICTCLAS 是吕震宇移植的 C# 版本。这两个版本也采用“自然语言处理开放资源许可证”。

2009年中科院高小平针对 Lucene 用 Java 重写了 ICTCLAS 代码,采用 Apache Licence 2.0 协议公开了源码和词库数据,目前已并入了 Lucene contrib 代码树中。我花了些时间将这个版本转写为 C# 版。

下图表示了这些版本间的关系:(红色为商业软件,紫色为“自然语言处理开放资源许可证”,绿色为“Apache Licence 2.0”)

目前讨论较多的切词器,如庖丁解牛盘古分词等,多采用查词典的方式切分,词典质量决定切分效果。

ICTCLAS 切词基于概率统计的语料库(高小平称之为“智能词典”),算法基于“层叠式隐含马尔可夫模型”(Hierarchical Hidden Markov Model, HHMM)。仅就算法而言,应该说这是一种较为先进的方法,Google 研究员也说“统计语言模型比任何已知的借助某种规则的解决方法都有效”(数学之美系列)。

但是高小平贡献的版本(包括我转写的版本),从 ICTCLAS 中去除了一些功能,如:词性标注、人名识别、日期识别等等,特别是后两项,切分结果在这方面不是很理想。另外,“智能词典”里存储的是各种词汇出现的概率,是通过机器训练得到的,难以采用人工方式维护,这也是一个不方便之处。

关于我转写的版本:基本上没有改变高小平版的代码逻辑,仅将 Lucene Tokenizer 等相关接口改用了新版的接口标准(IncrementToken 方式)。

下载 (Apache Licence 2.0)

posted @ 2011-02-11 05:04 破宝 阅读(185) 评论(2) 编辑

2011年2月10日

SQLite全文检索(2)

距上一篇有好久了,因为乏人问津所以一直也没写这第二篇。年前看到有人给我发消息问 SQLite 全文检索的事,我想哪怕只有一个人看吧,我也整理整理。这一篇就写写如何扩展 SQLite 使它支持东亚文字的切词。

熟悉 Lucene 的童鞋大概知道,切词是在索引时进行的。对 SQLite 来说,也就是 INSERT UPDATE 时发生切词。SQLite 的做法是,在定义 FTS 虚表时指定切词器:

CREATE VIRTUAL TABLE pages USING fts3(title, body, tokenize=porter);

还记得“porter”吗?当然这里不是哈利波特,其实是指 Martin Porter 设计的切词算法。或许你在 Lucene 里见过,这个切词器主要用于英语词的整形(如复数变单数,去词尾变词根等等)。porter 是 SQLite 内置的切词器,可以直接使用。而我们需要扩展自己的切词器。

SQLite 是一个 C 语言开发的、定位于嵌入型的轻量级数据库,因此它的切词器接口也是以 C 语言的形式给出的。这里仅简单介绍一下:

(1) SQLite 要求你首先创建一个结构:

[StructLayoutAttribute(LayoutKind.Sequential)]
internal struct sqlite3_tokenizer_module
{
	public int iVersion;
	public sqlite3_tokenizer_module_xCreate xCreate;
	public sqlite3_tokenizer_module_xDestroy xDestroy;
	public sqlite3_tokenizer_module_xOpen xOpen;
	public sqlite3_tokenizer_module_xClose xClose;
	public sqlite3_tokenizer_module_xNext xNext;
}

除了 iVersion 是常数之外,其余几个字段都是函数指针,分别是切词器生命周期各阶段的回调函数。其中 xNext 函数是重点,用于返回下一个切好的词。

(2) 然后将上面的这个结构体的内存地址,通过下面的 SQL 语句告诉给 SQLite:

SELECT fts3_tokenizer('demo', <sqlite3_tokenizer_module ptr>);

比如这句注册了名叫 demo 的切词器。注册之后就可以使用这个切词器了:

CREATE VIRTUAL TABLE pages USING fts3(title, body, tokenize=demo);

简单说起来只是这两步,但实现过程对于 C# 程序员来说,还是不太容易的,因为我们并不经常直接和函数指针、内存地址这些东西打交道。

实现过程中比较关键的几点是:

(1) 必须将回调函数,以及上面提到的接口 module 结构体,放到非托管内存领域。因为托管内存是 CLR 管理的,垃圾回收随时会启动,对象也可能被移动位置,回调函数和内存地址随时都会失效(尤其是切词处理时有大量数据进进出出,垃圾回收也会很频繁)。

Tip:可以先用 Marshal.AllocHGlobal 申请一段非托管内存,然后用 Marshal.StructureToPtr 将结构体写入非托管内存。但必须注意:放入非托管内存空间的结构体,一定要在使用完毕后手动释放(Marshal.FreeHGlobal)。

(2) 即便写入了非托管内存,关了程序切词器也就没了,所以每次连接到 SQLite 时,只要操作将要涉及到 FTS 虚表,都必须重新注册切词器。


好了,下面开始上主菜~

你已经看到,这个实现过程中有大量的代码要在非托管内存进行,需要小心翼翼的处理,一不留神就会出问题。因此,有必要做一些封装,将这些实现细节隐藏起来,方便 .NET 开发者扩展新的切词器。

下面这个是我封装后的抽象基类,只贴出接口部分:

public abstract class SQLiteFtsTokenizer
{
	/// <summary>
	/// 切词器名称。也就是 tokenize=**** 处写的那个名称,请重写此属性。请用英文字母。
	/// </summary>
	public virtual string Name
	{
		get { return "custom"; }
	}

	/// <summary>
	/// 注册切词器。参数是 SQLite 连接。
	/// </summary>
	public void RegisterMe(SQLiteConnection connection) { }

	/// <summary>
	/// 切词器刚创建时的处理。(可选)
	/// </summary>
	/// <param name="tokenizerArgument">The argument for tokenizer.</param>
	protected virtual void OnCreate(string tokenizerArgument) { }

	/// <summary>
	/// 切词器销毁前的处理。(可选)
	/// </summary>
	protected virtual void OnDestroy() { }

	/// <summary>
	/// 切词器开始工作前的初始化。
	/// </summary>
	protected abstract void PrepareToStart();

	/// <summary>
	/// SQLite 传出的、需要切词的字符串(只读)。
	/// </summary>
	protected string InputString
	{
		get { return this.inputString; }
	}

	/// <summary>
	/// 尝试读取下一个 Token。
	/// </summary>
	/// <returns>成功读取 Token 返回 true,读取结束返回 false。</returns>
	protected abstract bool MoveNext();

	/// <summary>
	/// 读取到的 Token。
	/// </summary>
	protected string Token
	{
		get { return this.token; }
		set { this.token = value; }
	}

	/// <summary>
	/// 读取到的 Token 在 InputString 的位置(从 0 起算)。
	/// </summary>
	protected int TokenIndexOfString
	{
		get { return this.tokenIndexOfString; }
		set { this.tokenIndexOfString = value; }
	}

	/// <summary>
	/// 下一次读取应该开始的位置(从 0 起算)。如果下一次读取正好在此次 Token 的后面,可以返回 -1。(目前我还未发现它的影响)
	/// </summary>
	protected int NextIndexOfString
	{
		get { return this.nextIndexOfString; }
		set { this.nextIndexOfString = value; }
	}

	/// <summary>
	/// 开发测试用。返回值是切完的 Token 列表。
	/// </summary>
	public List<string> TestMe(string inputString) { }
}

有了这个基类,扩展出我们自己的切词器就比较容易了。我在下载压缩包里放了一个 CJKTokenizer。参考了车东为 Lucene 写的 CJKTokenizer 的做法,采用的是二元切词法,比如“清华大学”将切为“清华/华大/大学”三个 Token。

最后,看一下自定义 Tokenizer 的使用代码示例:

using (SQLiteConnection connection = new SQLiteConnection("Data Source=filename"))
{
    CJKTokenizer tokenizer = new CJKTokenizer();
    connection.Open();
    tokenizer.RegisterMe(connection); //注册切词器

    //建表
    SQLiteCommand cmd = new SQLiteCommand(connection);
    cmd.CommandText = "CREATE VIRTUAL TABLE docs USING fts3(title, content, tokenize=cjk)";
    cmd.ExecuteNonQuery();

    //插入数据
    cmd.CommandText = "INSERT INTO docs (title, content) VALUES (?, ?)";
    SQLiteParameter p1 = new SQLiteParameter();
    p1.DbType = System.Data.DbType.String;
    p1.Value = "测试标题";
    cmd.Parameters.Add(p1);
    SQLiteParameter p2 = new SQLiteParameter();
    p2.DbType = System.Data.DbType.String;
    p1.Value = "测试内容";
    cmd.Parameters.Add(p2);
    cmd.ExecuteNonQuery();

    //检索
    cmd.CommandText = "SELECT docid, title, content FROM docs WHERE docs MATCH '测试'";
    SQLiteDataReader dr = cmd.ExecuteReader();
    while(dr.Read())
    {
        //...
    }
    dr.Close();

    connection.Close();
}

其实只多了两行代码:一行 new ,一行注册切词器。

现有的切词器大多针对 Lucene 开发,如果不想改动太多代码,可以采用“适配器模式”,为 Lucene Tokenizer(TokenFilter)套一个 Adapter。压缩包里有一份毛胚版的参考实现。

(此系列的下一篇将写写根据相关度排序的话题,看看有没有人捧场吧~)

代码下载

posted @ 2011-02-10 07:33 破宝 阅读(434) 评论(6) 编辑

2010年8月18日

SQLite全文检索(1)

SQLite 是一款非常轻量的嵌入型数据库,没有独立的进程,非常小的 footprint,零配置,支持事务,“public domain”开源,对于客户端程序来说已经游刃有余。[更多的介绍System.Data.SQLite 库]

说到全文检索,目前比较流行、也比较成熟的选择是 Lucene.net。今天给大家介绍的是 SQLite 内置的全文检索功能,以如此小的 footprint 实现全文检索功能,我想还是有一定吸引力的。国内目前涉及此领域的文章还很少,.net 圈估计本文是第一篇吧,能力有限,多多包涵。

要使用 SQLite 全文检索,首先要创建 VIRTUAL TABLE:

CREATE VIRTUAL TABLE pages USING fts3(title, body);

虚表 pages 包含 title, body 两个文本字段,此外还有一个 docid 整数型的内置字段(你可以自行为 docid 赋值,也可以插入 NULL 让系统自动分配)。虚表可以和其他普通表类似的操作、连接等。

INSERT INTO pages (docid, title, body) VALUES (1, 'hello, world', '"hello, world"is my first line of data.');
UPDATE pages SET title='hello, world !' WHERE docid=1;
SELECT title, body FROM pages INNER JOIN pageinfo ON pageinfo.docid=pages.docid WHERE pageinfo.SiteName='sina';

虽然你可以写 WHERE title=’hello, world !’ 这样的查询,但这样做首先效率上比较糟糕(SQLite 将做全表扫描),而且体现不出全文检索的好处来。实际上应该是用 MATCH 运算符:

SELECT title, body FROM pages WHERE pages MATCH 'world';
SELECT title, body FROM pages WHERE title MATCH 'world';

注意这两句,前一个 MATCH 左边写了表名,后一个写的是列名。后一个仅搜索 title 列,前一个是搜索全部列(docid 列以外)。

MATCH 右侧的表达式支持模糊查询、支持指定列查询、支持 AND/OR/NEAR/NOT 等运算:

SELECT title, body FROM pages WHERE pages MATCH 'hel*';
SELECT title, body FROM pages WHERE pages MATCH 'title:hello';
SELECT title, body FROM pages WHERE pages MATCH 'hello AND world';
SELECT title, body FROM pages WHERE pages MATCH '(hello NEAR world) OR (program AND language)';

但要注意只能出现一次 MATCH 判断,WHERE title MATCH 'hello' AND body MATCH 'world' 是不行的,可以改作 WHERE pages MATCH 'title:hello AND body:world'。

我们一般都希望检索结果能给出一片段,并标出所查关键词,此时可以使用 snippet 函数:

SELECT title, snippet(pages, '<strong>', '</strong>', '...') FROM pages WHERE pages MATCH 'hello';

将会选出一两个包含检索关键词的片段,并在关键词两边用 <strong> 标记出来; '...' 将用在片段的间隔和末尾。如果略去后三个参数,则默认是以 <b> 标签标关键词。

【提示】

1. 从sqlite.org 下载的 SQLite 版本默认是不支持全文检索功能的,需要修改编译参数重新编译。不过,.net 开发者一般直接使用从 phxsoftware.com 下载的 System.Data.SQLite.dll 版本,这个版本是默认开启全文检索功能的。

2. SQLite 内置的切词器只支持西方文字,对缺乏空格分隔的东亚文字无能为力,我们需要做一些扩展让它支持东亚文字的切词。我们下一篇就来讨论这一话题。

3. 我们希望检索结果能按照相关度排序,这需要你先研究明白 offset,matchinfo 函数的用法,然后根据 SQLite 的官方文档的示例,写出自定义的相关度计算函数,然后再据此进行排序。如果有机会,我再单独写一篇这个话题的内容。

posted @ 2010-08-18 19:52 破宝 阅读(592) 评论(0) 编辑

2009年12月24日

80块钱毁掉“猪八戒”的信誉

大约八九月份误打误撞进了这个名为“猪八戒”的网站,这是一个“威客”网站,或者说“外包”网站,或者简单说是“发任务”“做任务”的网站。当时没有这方面的需要,也就没有深入了解。十一月底十二月初的时候,因为手上编写的一款软件需要做一些宣传推广方面的设计工作,就又想起了“猪八戒”,想通过“猪八戒”找人帮忙做一些宣传图稿和文案。发任务之前,也对同类的几家“威客”网站做了一些基本的调查,发现“猪八戒”的信誉还是不错的,在国内同行里至少是前三名以内的。

我在“猪八戒”上发的两个任务,进展得还算顺利,按时完成了我预定目标。因为发这两个任务的原因,我在这段时间里经常到“猪八戒”网站上转悠,搜集同类任务的价格信息、学习其他人书写任务说明的方法、查看投标作品的进度、留意参与者发来的站内消息等等。

十二月一号那天,我在转悠的时候,偶然看到了一个编程方面的任务,简单看了一下要求,是关于浏览器控件的代码控制问题的,作为一个程序员,觉得这有点意思、想试试自己能不能解决。

http://www.zhubajie.com/task/iv/178969

然后就简单写了写,也没花多少时间,不到十分钟就搞定了,索性就又花十来分钟把代码整理出来、简单写了说明,随手发到“猪八戒”网站上应征这个任务。

一个多小时后,我看到雇主的评价,至少我交的代码是管用的:

于是我开始想这个任务应该可以中标了,按照“猪八戒”的规则,中标后我能拿到总赏金的80%、也就是80块钱。

雇主通过站内消息把他的QQ号告诉了我:

我加了他的QQ,雇主是个大忙人,直到125号才通过请求:

QQ资料显示雇主在广东惠州:

这个任务本应在1211号结束,可雇主一直在等待更好的方案,没有及时选标。按照“猪八戒”的规则,雇主还有三天的选择时间,这也不算什么特别的。

12号傍晚当我又一次查看站内消息时,看到一封系统消息:

我按照系统消息的要求,加了客服的QQ,可刚说了几句,那头就下班没回应了。没办法,只好等第二天再说。同时,我也给雇主的QQ留了言,说了这件事,请他及时选标。当天晚上,他选了我的标。这是12号的事情。

之后有一个三天的公示期,我也没在意,想着应该没什么问题了。就这么安静地过了一个多星期。

20号晚上,我突然看见雇主给我发来QQ消息:

我去看网站里的“任务日志”,确实中标资格被取消了,并且被扣了一个“作弊”的屎盆子:

甚至,从这件事发生的1215号一直到雇主告诉我这件事长达五天时间里,网站方面没有和我有过任何联系(电话、Email、甚至站内消息都没有)。仅仅一个批注,就颠倒了一切。

正如雇主所提到的,稿件中无缘无故多出一个“入围”来:

他曾经多次投稿,下面是最后一次雇主给他的评价,从这里看得出,选这个人的作品“入围”是违反雇主意愿的:

雇主神龙见首不见尾,他说他上班的时候没法打电话,他下班的时候“猪八戒”也下班,这件事一直拖着得不到解决。

我给“猪八戒”的客服QQ发消息反映此事,结果石沉大海。我又试着通过网站投诉,客服的回复非常敷衍:

21号试着给“猪八戒”打电话,对方坚称他们有证据证明我和雇主作弊,但是拒绝告诉我作弊证据,称有两个约束条件:第一只能给雇主,我作为投标者无权索取;第二,雇主必须给他们发书面的索取函。

 

事件经过已经讲完,简单列一列“猪八戒”的罪状:

l  “猪八戒”贪图区区80块钱的小利,就无视雇主、投标者双方的利益。

l  仅仅由于有人投诉,就做出作弊的裁断。丝毫不和当事人联系、以确认身份,仅凭投诉人一面之词就做出决定。

l  不仅事前没有联系当事人,事后也不主动通知,企图蒙混过关,昧掉80块钱。

l  违背雇主意愿,随意添加“入围”稿件。

l  以公司内部的规定为由,拒不给出所谓的作弊证据。

 

雇主和投标者双方的利益都得不到保障,谁还敢信任你们?

没错,“猪八戒”看准了一条,即使他们贪了这80块钱,也没人拿他们怎么着,不会有人去告他们,这点钱太少不值得。

“猪八戒”你记住:好事不出门,坏事传千里!

作恶一次,即使行善百次也是无法弥补的!丢了信誉,你的“威客”也就快玩完了。

  

PS:大家都是行家,也可以看看那个任务要求和我提交的稿件(未加密),如果有更好算法的话,也可以一起讨论讨论。

posted @ 2009-12-24 08:27 破宝 阅读(673) 评论(14) 编辑

2009年6月15日

有点郁闷:MSDN文档中MidpointRounding.AwayFromZero的翻译错误

很早就知道 Math.Round 方法实际上并不是我们上学时学到的“四舍五入”,而是 IEEE 标准定义的“银行家舍入”算法,通俗说法是“四舍六入五成双”(1.5→2, 4.5→4)。

当需要用到“四舍五入”算法时,.NET 1.x 中是需要自己实现(比如一种思路:正数加0.5后Math.Floor,负数减0.5后Math.Ceiling)。.NET 2.0 开始,Math.Round 方法提供了一个枚举选项 MidpointRounding.AwayFromZero 可以用来实现传统意义上的“四舍五入”。即: Math.Round(4.5, MidpointRounding.AwayFromZero) = 5。

不过MSDN文档中这个MidpointRounding.AwayFromZero的描述是“当一个数字是其他两个数字的中间值时,会将其舍入为两个值中绝对值较小的值。”弄的我很长时间没反应过来。

实际上从 .NET 2.0 发布以来,网上已经有很多人都指出了这个翻译错误(原文实际是 When a number is halfway between two others, it is rounded toward the nearest number that is away from zero.),实际上应该是取绝对值较大的值

可是很遗憾,.NET 3.0, .NET 3.5 的文档中,这个翻译错误依然没有改正。

P.S.附带地,你有没有注意过:Math.Floor(2.1) = 2 而 Math.Floor(-2.1) = -3,也就是所谓“向负无穷大方向舍入”; Math.Ceiling(2.1) = 3 而 Math.Ceiling(-2.1) = -2,也就是所谓“向正无穷大方向舍入”。比如说,Ceiling 的一个使用场景:一个袋子能装 10kg,则 21kg 货物需要三个袋子,如果是 -21kg 呢?Math.Ceiling 的上述行为,是否也不符合你的预期呢?

posted @ 2009-06-15 11:53 破宝 阅读(27) 评论(0) 编辑

2009年2月20日

当 ASP.net Mobile Controls 碰到“中国特色”的 CMWAP / UNIWAP

posted @ 2009-02-20 00:35 破宝 阅读(21) 评论(0) 编辑

2008年9月24日

闲话“正版”:正版软件和盗版软件的区别到底是什么?

posted @ 2008-09-24 08:22 破宝 阅读(19) 评论(0) 编辑

闲话“正版”:真是因为“缺钱”吗?

摘要: 最近有一条争议不小的新闻:微软(中国)在国庆节期间的促销活动,Office家庭版降价到199元。这让我想到了我三年多前的一篇博文,梦呓:微软在中国的新定价策略——比例折算法。这个价格可以说比“比例折算法”还要实惠。不过遗憾的是,网上投票情况显示,半数以上的网民对此促销活动并不买账,认为只要比盗版价格高就绝不买正版。这也如实反映了国民对“正版”的认识程度。很多人拒绝正版的理由是没钱。但另一方面却发现,大家对于硬件选择相当的“阔绰”“奢侈”,全部组件都要选择最高等级,CPU要市场上最快的,硬盘要转速高容量大的,要外加两三千的专业级独立显卡,再加杜比5.1的家庭影院,呵呵,无论自己的实际需求是否真的阅读全文

posted @ 2008-09-24 07:00 破宝 阅读(11) 评论(0) 编辑

2008年6月16日

又一个疑似Bug: XmlDataSource 控件的 Data 属性动态改变时,缓存不会自动失效

posted @ 2008-06-16 20:16 破宝 阅读(20) 评论(0) 编辑

2008年6月10日

立此存照:System.Net.Mail 的 bug

摘要: 痛苦了debug了一个多钟头,后来终于在网络上找到了这篇“救星”文章:http://columns.chicken-house.net/blogs/chicken/archive/2007/04/06/system-net-mail-bug.aspx立此存照,如果您也碰到同样问题,希望能够能比我更幸运些,更早找到问题所在。症状是:调用 SmtpClient.Send 方法后,出现 System.FormatException,英文消息为“An invalid character was found in header value.”中文消息是:“邮件标头中找到无效字符”。原因是在 SmtpCl阅读全文

posted @ 2008-06-10 18:25 破宝 阅读(14) 评论(0) 编辑