泰国地址识别的一种尝试
前言
由于公司项目主要面向泰国等东南亚地区用户,参考国内各大快递、电商平台有关收货地址的自动识别,希望能实现类似的、基于泰文的泰国收货地址自动识别的功能。用户粘贴一段“姓名 + 收货手机号 + 收货地址 + 邮编”的文字,通过分析、匹配上系统内已经存在的、由物流公司提供的府(จังหวัด)、县(อำเภอ)、区(ตำบล)的邮政编码。作为一个国人程序员,自然是没有泰语功底,因此本文中所提到的有关泰语的相关说明和描述可能存在偏差,但本文仅作为实现自动识别地址功能的一种参考。
了解泰国地址
在说泰国地址之前,我们先来说说中国大陆地区的地址。参考国内顺丰速运提供的国内收货地址识别,顺丰已经实现了用户粘贴一段“中文姓名 + 收货手机号 + 收货地址”即可准确识别出地址中的省、市、区等信息,并能从中提取收货人的中文姓名和手机号。从中华人民共和国国家统计局和中华人民共和国民政部,我们可以获取到国家每年统计的行政区划和行政区划代码。国家官方发布的信息对国内地址识别有一定帮助。
相较于国内权威发布的统计数据,泰国发布的数据就相对较少了。通过 Google 和公司内从事泰语翻译的同事了解到,类似于英语中地址的写法,泰语中地址的书写顺序是倒序,从最小的单位开始写,一直到最大的单位。一般在告知地址时,会用如下字样:ติดต่อเรา / ที่อยู่(联系我们/地址)。以曼谷素万那普机场为例,地址如下:
อาคารผู้โดยสาร ชั้น 6 (แถว F, ประตูทางเข้าที่ 3) 999 หมู่ 10 ถนน บางนา-ตราด ตำบลราชาเทวะ อำเภอบางพลี จังหวัดสมุทรปราการ 10540
出现的结构单位有:ชั้น6(六楼)、แถว F(F道)、ประตูทางเข้าที่ 3(3号门),由于机场较为特殊,如果是普通住户或单位,则这里是住房编号。接下来的单位是:หมู่ 10(十号巷)、 ถนน(路)、ตำบล(区)、 อำเภอ(县)、จังหวัด(府)。以上基本是除曼谷以外的地名的基本表达方法。
曼谷的地址较为特殊,以曼谷一家泰国银行地址为例,地址如下:
333 ถ.สีลม แขวงสีลม เขตบางรัก กรุงเทพมหานคร 10500
这里 ถ. 是 ถนน(路)的简写。在泰文地址书写当中,经常会出现行政单位简写,具体缩略方式是首字母加点。其他单位缩写还有 จ.(จังหวัด,府)、อ.(อำเภอ,县)、ต.(ตำบล,区)、ซ.(ซอย,巷)。以上地址接下来是 แขวง(主干道)、เขต(区域)、กรุงเทพมหานคร(曼谷)。
主要思路
介绍之前,先介绍一下这次泰国地址自动识别的思路。
- 泰文分词,整理分词结果
- 从分词结果中找出所有地址结果单位的待定项
- 含有邮编:对比地址库与各待定项的匹配度,选择相似度最高的一组
- 邮编未知:找出待定项与地址库所有匹配的地址记录
- 拾取手机号部分,计算距离,选择距离最近的部分
泰文分词
说到分词,想到的一定是机器学习,考虑到项目无法提供大量泰文进行学习,公司也没有人专门做数据标记,无法自建一套泰文分词库。此路不通后,开始考虑云服务提供商。翻遍国内的阿里云、腾讯云、华为云、京东云、新浪云、百度云,虽然部分云提供地址识别和中文分词服务,但目前仅阿里云提供了中文、英文、泰文三种语言的分词服务,且目前 NLP 服务的免费额度也足够中小企业使用。
由于项目目前还有其他功能使用阿里云服务,因此接入 NLP 服务相对比较简单,验签方式与其他 API 相似,整个接入过程顺畅。(真的没有要给阿里云打广告的意思)
由于泰国目前仍会主要使用邮政编码,因此用户输入的地址有两种情况,一种是地址中含有目的地邮政编码,一种则不含有。
第一种含有目的地邮编,以如下这个泰国地址(部分敏感数据已处理)为例。
น.ส สมหญิง ศรีเรือง 0628888888 333หมู่1 ต.ตรมไพร อ.ศีขรภูมิ จ.สุรินทร์ 32110
调用阿里云 NLP 通用分词接口,可以得到以下分词结果:
{"data":[
{"id":0,"word":"น"},{"id":1,"word":"."},{"id":2,"word":"ส"},
{"id":3,"word":" "},{"id":4,"word":"สมหญิง"},{"id":5,"word":" "},
{"id":6,"word":"ศรีเรือง"},{"id":7,"word":" "},{"id":8,"word":"0628888888"},
{"id":9,"word":" "},{"id":10,"word":"333"},{"id":11,"word":"หมู่"},
{"id":12,"word":"1"},{"id":13,"word":" "},{"id":14,"word":"ต"},
{"id":15,"word":"."},{"id":16,"word":"ตรมไพร"},{"id":17,"word":" "},
{"id":18,"word":"อ"},{"id":19,"word":"."},{"id":20,"word":"ศีขรภูมิ"},
{"id":21,"word":" "},{"id":22,"word":"จ"},{"id":23,"word":"."},
{"id":24,"word":"สุรินทร์"},{"id":25,"word":" "},{"id":26,"word":"32110"}]}
第二种不含有目的地邮编,以如下这个泰国地址(部分敏感数据已处理)为例。
88/2 หมู่8 เขาชะงุ้ม โพธาราม ราชบุรี ปั้นกล่ 098-8888888
调用阿里云 NLP 通用分词接口,可以得到以下分词结果:
{"data":[
{"id":0,"word":"88"},{"id":1,"word":"/"},{"id":2,"word":"2"},
{"id":3,"word":" "},{"id":4,"word":"หมู่"},{"id":5,"word":"8"},
{"id":6,"word":" "},{"id":7,"word":"เขา"},{"id":8,"word":"ชะงุ้ม"},
{"id":9,"word":" "},{"id":10,"word":"โพธาราม"},{"id":11,"word":" "},
{"id":12,"word":"ราชบุรี"},{"id":13,"word":" "},{"id":14,"word":"ปั้น"},
{"id":15,"word":"กล่"},{"id":16,"word":" "},{"id":17,"word":"098-8888888"}]}
从结果中得知,分词结果可能会将手机号码等连续数字断开,同时泰文中输入手机号码可能会存在横杠“-”字符,因此需要将连续数字拼接回来,同时忽略数字与数字之间的横杠字符和空白字符。将 098-8888888 变为 0988888888。
找出待定项
用户通过输入一段地址字符串,我们无法得知用户输入的地址是属于以上哪一种地址,同时为了能够适应泰国地址单位简写,我们需要从分词结果中循环找出府、县、区、邮政编码、手机号的待定项,即将数组中可能成为府、县、区、邮政编码、手机号的值的位置(下标)存起来。
foreach ($words as $i => $word) {
if (empty(trim($word)) || $word == '.') {
continue;
}
if ($word == self::PROVINCE_SHORT) { // PROVINCE_SHORT 为府(จ)简写
$usedPos = array_merge($usedPos, $this->findRelatedPart($i, $count, $words, $provinces));
continue;
}
if ($word == self::COUNTY_SHORT) { // COUNTY_SHORT 为县(อ)简写
$usedPos = array_merge($usedPos, $this->findRelatedPart($i, $count, $words, $counties));
continue;
}
if ($word == self::DISTRICT_SHORT) { // DISTRICT_SHORT 为区(ต)简写
$usedPos = array_merge($usedPos, $this->findRelatedPart($i, $count, $words, $districts));
continue;
}
if (mb_strstr($word, self::PROVINCE)) {
$provinces[$i] = mb_substr($word, 0, -(strlen(self::PROVINCE)));
array_push($usedPos, $i);
continue;
}
if (mb_strstr($word, self::COUNTY)) {
$counties[$i] = mb_substr($word, 0, -(strlen(self::COUNTY)));
array_push($usedPos, $i);
continue;
}
if (mb_strstr($word, self::DISTRICT)) {
$districts[$i] = mb_substr($word, 0, -(strlen(self::DISTRICT)));
array_push($usedPos, $i);
continue;
}
if (preg_match("/^([1-9]\d{4})$/", trim($word), $matches)) { // 查找邮编
$postcodes[$i] = $matches[0];
array_push($usedPos, $i);
continue;
}
if (empty($phone) && preg_match("/^(0[2-3]\d-?\d{6}|0[1,4-9]\d-?\d{7})$/", trim($word), $matches)) { // 查找手机号
$phone[$i] = $matches[0];
array_push($usedPos, $i);
continue;
}
}
这里在匹配简写府、县、区时,我们需要匹配的是点“.”后面的府、县、区的名称(如 เมือง),而不是带点的名称(如 อ. เมือง)。由于分词结果中点“.”后可能存在空格或特殊空白字符,因此上面代码中 findRelatedPart
函数中对空格和特殊空白字符会跳过处理,截取点“.”后出现的第一个非空元素,并将这个元素放入府、县、区待定项数组中,同时返回所有使用到的元素在分词结果中的下标位置,包括府、县、区的简写、名称和点。以下为 findRelatedPart
函数:
function findRelatedPart($index, $wordsCount, array $words, array &$dataset)
{
$used = [];
for ($j = $index + 1; $j < $wordsCount; $j++) {
if (mb_ord($words[$j]) == 8203 || $words[$j] == '.') {
array_push($used, $j);
continue;
}
if (!empty(trim($words[$j]))) {
$dataset[$j] = $words[$j];
array_push($used, $j);
break;
}
}
array_push($used, $index);
return $used;
}
经过筛选后,上面提到的两种示例地址得到的结果如下:
// 第一种含有目的地邮编
$provinces = [
"24": "สุรินทร์"
];
$counties = [
"20": "ศีขรภูมิ"
];
$districts = [
"16": "ตรมไพร"
];
$postcodes = [
"26": "32110"
];
$phone = [
"8": "0628888888"
];
$usedPos = [8,15,16,14,19,20,18,23,24,22,26];
// 第二种不含有目的地邮编
$provinces = [];
$counties = [];
$districts = [];
$postcodes = [];
$phone = [
"17": "098-8888888"
];
$usedPos = [17];
含有邮编:对比地址库与各待定项的匹配度
通过上述所说取得邮政编码的所有待定项,将这些项在数据库地址库中进行查询,找到对应的地址邮编记录。
foreach ($postcodes as $postcode) {
$postcodeRows = $addressService->findByPostCode($postcode);
if ($postcodeRows->isNotEmpty()) {
$options = $options->concat($postcodeRows);
}
}
由于泰国邮政编码可能会一个邮编对应多个区,因此可以得到多个存在可能性的区。通过每个区找到区的上一级县和上两级府的名字,并将它们与原始分词结果计算相似度。
function computeSimilar($text, array $possibleTexts, array $usedPos = [])
{
$max = -1;
$index = -1;
foreach ($possibleTexts as $i => $t) {
if (in_array($i, $usedPos)) {
continue;
}
$percent = 0.00;
similar_text($text, $t, $percent);
if ($percent > $max) {
$max = $percent;
$index = $i;
}
}
return [$max, $index];
}
由区名称相似度、县名称相似度和府名称相似度计算一个平均相似度。假设平均相似度大于设定的阈值时,则将这组区、县和府作为备选放入最终地址可能性数组中。
foreach ($options as $index => $areaOption) {
$_usedPos = [];
list ($provincePercent, $provIndex) = $this->computeSimilar($areaOption->province->name, $words);
array_push($_usedPos, $provIndex);
list ($countiesPercent, $countyIndex) = $this->computeSimilar($areaOption->county->name, $words, $_usedPos);
array_push($_usedPos, $countyIndex);
list ($districtsPercent, $districtIndex) = $this->computeSimilar($areaOption->district->name, $words, $_usedPos);
array_push($_usedPos, $districtIndex);
if ($provincePercent < 0) $provincePercent = 0;
if ($countiesPercent < 0) $countiesPercent = 0;
if ($districtsPercent < 0) $districtsPercent = 0;
$percent = round(($provincePercent + $countiesPercent + $districtsPercent) / 3, 2);
if ($percent >= $this->similarity) { // $this->similarity 为设置的阈值
$percents[$index] = $percent;
$indexes[$index] = [$provIndex, $countyIndex, $districtIndex];
}
}
对于备选的地址可能性数组,我们再通过相似度降序排序,并取排序结果的第一条记录,即为地址解析结果。
arsort($percents);
$_index = array_key_first($percents);
$_indexes = $indexes[$_index];
$address = $options->get($_index);
// 维护分词结果中已使用的下标位置
array_push($usedPos, $_indexes[0], $_indexes[1], $_indexes[2]);
$provinces[$_indexes[0]] = $words[$_indexes[0]];
$counties[$_indexes[1]] = $words[$_indexes[1]];
$districts[$_indexes[2]] = $words[$_indexes[2]];
// 地址解析结果
$parseResult['level'] = $address;
$parseResult['indexes'] = $_indexes;
$parseResult['similarity'] = $percents[$_index] ?? '';
第一种含有目的地邮编的示例泰国地址分析结果如下:
"level" => App\Fragments\AddressLevel {#1631}
+province: App\Models\Address {#1713}
+county: App\Models\Address {#1712}
+district: App\Models\Address {#1711}
+postcode: App\Models\Address {#1668}
}
"indexes" => array:3 [
0 => 24
1 => 20
2 => 16
]
"similarity" => 100.0
邮编未知:找出待定项与地址库所有匹配的地址记录
上面已经介绍了邮编确定的情况,相较于邮编未知,邮编确定更好匹配,而对于不确定邮编的时候,这里是将上面找出的府、县、区待定项与地址库中所有的府、县、区进行匹配。由于府的数量要远小于县,同理县的数量也一般小于区,因此这里先进行府的字符串相似度匹配,再对县和区做相似度计算,一级一级向下匹配。
// 匹配府待定项与地址库中府
$allProvinces = $addressService->findByTypeAndParentIds(Address::PROVINCE);
$possibleProvince = $this->calculateSimilarityByCompare($words, $allProvinces, $provinces);
// 匹配县待定项与地址库中县
$allCounties = $addressService->findByTypeAndParentIds(
Address::COUNTY,
$possibleProvince->pluck('id')->toArray()
);
$possibleCounties = $this->calculateSimilarityByCompare($words, $allCounties, $counties);
// 匹配区待定项与地址库中区
$allDistrict = $addressService->findByTypeAndParentIds(
Address::DISTRICT,
$possibleCounties->pluck('id')->toArray()
);
$possibleDistricts = $this->calculateSimilarityByCompare($words, $allDistrict, $districts);
// 匹配邮政编码
$possiblePostcode = $addressService->findByTypeAndParentIds(
Address::POSTCODE,
$possibleDistricts->pluck('id')->toArray()
)->keyBy('parent_id');
这里计算匹配的相似度时需要注意,如果待定项是通过地址结构单位简写找出的,则在计算相似度时需要将简写部分去除后再将两者进行计算。与邮编已知同理,相似度大于设定的阈值时,则将这组区、县和府作为备选放入最终地址可能性数组中。
function calculateSimilarityByCompare($words, $dataset, $filteredOptions)
{
$possibleOptions = collect();
foreach($dataset as $data) {
$percent = 0.00;
$percentShort = 0.00;
if (empty($filteredOptions)) {
$filteredOptions = $words;
}
foreach ($words as $k => $v) {
similar_text($v, $data['name'], $percent);
similar_text($v, mb_substr($data['name'], 0, strlen($v)), $percentShort);
if ($percentShort > $percent) {
$percent = $percentShort;
}
if ($percent >= $this->similarityWithoutPostcode) { // $this->similarityWithoutPostcode 为设置的邮编未知的相似度阈值
$possibleOptions->push([
'id' => $data['id'],
'name' => $data['name'],
'parent_id' => $data['parent_id'],
'similarity' => $percent,
'index' => $k
]);
}
}
}
return $possibleOptions;
}
由于上面匹配的府、县、区和邮编记录($possibleProvince
, $possibleCounties
, $possibleDistricts
, $possiblePostcode
)并非是相互隶属的,因此还需要再整理一下数据。对于备选的地址可能性数组,我们通过相似度降序排序,并取排序结果的第一条记录,即为地址解析结果。参考如下:
$possibleLocations = collect();
foreach($possibleProvince as $pp) {
$findedCounties = $possibleCounties->where('parent_id', $pp['id']);
foreach($findedCounties as $fc) {
$findedDistricts = $possibleDistricts->where('parent_id', $fc['id']);
foreach($findedDistricts as $fd) {
$postcode = $possiblePostcode->get($fd['id']);
$possibleLocations->push([
'province' => ...,
'county' => ...,
'district' => ...,
'postcode' => ...,
'similarity' => bcdiv($pp['similarity'] + $fc['similarity'] + $fd['similarity'], 3, 2)
]);
}
}
}
$match = $possibleLocations->sortByDesc('similarity')->first();
第二种目的地邮编未知的示例泰国地址分析结果如下:
"level" => App\Fragments\AddressLevel {#19073}
+province: App\Models\Address {#19026}
+county: App\Models\Address {#19033}
+district: App\Models\Address {#19034}
+postcode: App\Models\Address {#18904}
}
"indexes" => array:3 [
0 => 12
1 => 10
2 => 10
]
"similarity" => 100.0
拾取手机号,计算距离
由于找出待定项时 usedPos
代表的是所有待定项的下标位置,根据上面的两种方式找出地址后,需要重新计算上面找到的地址所使用了分词结果哪些元素。同时,为了提取姓名,需要计算出没有使用的下标中,连续的下标位置段,如 0, 5, 6, 7, 10, 14, 15, 19 则需要计算成 [[0], 5, 7, 10, 14, 15, 19]。
function collectConsecutive(array $positions)
{
$consecutivePos = [];
$tmp = $positions;
sort($tmp, SORT_NUMERIC);
for ($i = 0; $i < count($tmp); ) {
$k = $pos = $tmp[$i];
for ($j = $i; $j < count($tmp); $j++) {
if (in_array($k + 1, $tmp)) {
$k++;
} else {
array_push($consecutivePos, [$pos, $k]);
$i = $i + ($k - $pos) + 1;
break;
}
}
}
return $consecutivePos;
}
从上面找出的手机号待定项中,拾取手机号码,并计算手机号与各未使用的连续下标位置段之间的距离。
$consecutiveUnusedPos = $this->collectConsecutive($unusedPos);
$phonePos = array_key_first($phone);
$parseResult['phone'] = str_replace("-", "", $phone[$phonePos]);
$unusedPosDistanceInPhone = $this->computeDistances($consecutiveUnusedPos, $phonePos, true);
计算距离方式如下:
function computeDistances(array $consecutive, $destPos, bool $abs = false, $onlyDirection = '')
{
$distances = [];
foreach ($consecutive as $i => $cons) {
$left = $cons[0];
$right = $cons[1];
if ($onlyDirection == 'left' && $destPos < $left) {
continue;
}
if ($onlyDirection == 'right' && $destPos > $right) {
continue;
}
$distances[$i] = $destPos > $right ? $right - $destPos : $left - $destPos;
if ($abs) {
$distances[$i] = abs($distances[$i]);
}
}
return $distances;
}
最后对手机号与未使用的连续下标位置段之间的距离做升序排序,取得最近距离的一个连续下标位置段,将连续下标位置段从 $words
中取出则为收货人姓名,同时将这段下标位置段维护到已使用的下标位置 usedPos
。
同理,求出剩余部分连续的下标位置段,计算上面匹配出的区名称与各未使用的连续下标位置段之间的距离,取最近距离的一个连续下标位置段,将连续下标位置段从 $words
中取出则为自己填写的街道地址。
第一种含有目的地邮编的示例泰国地址分析结果:
"level" => App\Fragments\AddressLevel {#1631}
+province: App\Models\Address {#1713}
+county: App\Models\Address {#1712}
+district: App\Models\Address {#1711}
+postcode: App\Models\Address {#1668}
}
"indexes" => array:3 [
0 => 24
1 => 20
2 => 16
]
"similarity" => 100.0
"phone" => "0628888888"
"full_name" => "น.ส สมหญิง ศรีเรือง"
"address" => "333หมู่1"
"origin_text" => "น.ส สมหญิง ศรีเรือง 0628888888 333หมู่1 ต.ตรมไพร อ.ศีขรภูมิ จ.สุรินทร์ 32110"
"level_parse" => array:4 [
"province" => "สุรินทร์"
"county" => "ศีขรภูมิ"
"district" => "ตรมไพร"
"postcode" => "32110"
]
第二种目的地邮编未知的示例泰国地址分析结果:
"level" => App\Fragments\AddressLevel {#19073}
+province: App\Models\Address {#19026}
+county: App\Models\Address {#19033}
+district: App\Models\Address {#19034}
+postcode: App\Models\Address {#18904}
}
"indexes" => array:3 [
0 => 12
1 => 10
2 => 10
]
"similarity" => 100.0
"phone" => "0988888888"
"full_name" => "ปั้นกล่"
"address" => "88/2 หมู่8 เขาชะงุ้ม"
"origin_text" => "88/2 หมู่8 เขาชะงุ้ม โพธาราม ราชบุรี ปั้นกล่ 098-8888888"
"level_parse" => array:4 [
"province" => "ราชบุรี"
"county" => "โพธาราม"
"district" => "โพธาราม"
"postcode" => "70120"
]
总结
使用多个泰国地址,经过多次识别后发现,大部分含有邮编的地址识别成功率较高,识别速度也会较快,而邮编未知的情况则会比较糟糕,由于需要通过数据库遍历数据来匹配,因此识别速度较慢,识别成功率也会因为输入的地址出现很大差异,整体识别成功率比较低。同时,如果邮编未知时,将设定的相似度阈值降低,则会使得数据库遍历数据更多,速度更慢,而将阈值调高,则会使识别成功率降低,因此相似度阈值的设定也很有讲究。
阿里云的 NLP 自然语言处理的多语言分词为这次泰文分词提供了一定的帮助,但分析过程中发现,阿里云对泰文的语言分词还不算特别完善,因为泰文中府、县、区的名称不一定需要使用空格分开,即可能存在输入的地址中府、县、区名称连在一起,此时阿里云的多语言分词就未能较好的分开府、县、区名称。如果分词无法将其分开,那么后面的所有匹配都将很难做到很精确。
这次尝试对我是一种挑战,没有泰文语言基础,也没有分词相关的经验,凭借一些资料和已有的经验做出这个不完善的方法。希望本文对读者有一定帮助,也欢迎与我一同探讨和分享更好的解决方案,共勉。