摘要
Geohash在LBS领域的应用开发很常见,常常应用于查询附近的人或门店等应用程序中。这里不再介绍Geohash的原理,其原理详见:GeoHash核心原理解析。 这里主要讲一个Geohash的另一种应用:挖掘热点地名/地址信息,补充实体POI(Point Of interest)信息,辅助扩大检索召回。
目录
一、背景介绍
在LBS检索中,用户检索query往往是where+what的检索,例如:q=桂平市西山镇长安小学。为了提高检索准确率,必然是会想办法解析出where,桂平市、西山镇,然后给用户查找what=长安小学。如果提供给检索的数据(这里指POI点)信息很全,比如:数据的地址字段(addr)含有“西山镇”,又或者名字字段(name)包含西山镇,例如:长安小学(西山镇分校),又或者有其他地址信息中包含“西山镇”,都可以辅助引擎检索召回,同时,当长安小学有很多时,数据字段越丰富,越准确,还能进一步提升排序的合理性。
但现实世界往往是极其残酷的,说的不夸张一点,提供给检索引擎的数据,几乎都是东拼西凑的,数据制作工艺参差不齐。数据信息除了错,最大的问题之一就是信息不全,不够精细。特别是对于那些多源数据融合的检索数据,往往都存在类似问题。仅对LBS领域来说,一些小作坊的POI数据,往往存在缺失“省/市/区”三级以下的“地理信息”,即乡/镇/村/道路/道路门牌等。在这样的情况下,如何能够给那些“地理”信息不全的POI进行信息补全呢? 从而提高检索的召回率与准确率。 此文,正是要讨论解决的这一难题,它的解决方案,恰恰是Geohash的一个典型应用案例。
二、解决方案
思路:针对一个POI点,查找它附近点的地理信息。将附近点的地域信息经过一定的筛选和过滤,然后赋值给该POI点上的某个字段,从而补全该POI点的地理信息。
不难发现,一提及“附近”,这就很容易想到Geohash。这里提出一个极其简单的解决方案,在实际应用中,各位还需结合自己的业务进行完善。
前提条件:
<1> 已有“地名地址信息;行政区划”类目的POI数据,此类数据为:省、市、区、街道/乡/镇、村等行政区划的POI数据;
<2> 每个POI数据均具有名称、地址、省、市、区、经纬度、行政区划编码、类别等基础字段。
任务需求:利用以上“地名地址信息;行政区划”类目的POI数据,为其他POI点补充“地理信息” ,新增hot_place字段存放。
【注】 仅补充区级以下,不用补充省、市、区一级的地理信息,因为它们已经是基础字段信息了。默认是必备的。
具体解决方案与步骤
<1> 利用Geohash算法,对已有“地名地址信息;行政区划”类目的POI数据进行编码,构建词表,存放在town_geohash.map文件中。
<2> 遍历目标POI数据,利用经纬度字段计算出自身的Geohash值,再由该值查找出其附近8个格子的Geohash值。(类似9宫格,自身与其周边的8个方格,具体如图);
<3> 利用步骤2得到的9个格子的Geohash值,查找构建的行政区划词表(town_geohash.map),找出每个格子对应的行政区划数据(名称和经纬度),并计算每个行政区划数据与目标POI点的距离。
<4> 设定一个距离阈值r。目标POI点与要添加的行政区划数据的距离必须小于r,才能成为候选集。在候选集中,取距离最近的行政区划信息,补充至目标POI上,并存入hot_place字段。这里的筛选条件,是最简单的距离限制条件,并取最小值。
以上4个步骤,就完成了任务需求。需要注意的是,步骤4中设定的距离阈值r,其实是影响到Geohash精度的选取的,即Geohash值的长度。这里,需要注意;因为步骤<1>构建词表与步骤<2>计算每个目标POI点的Geohash,需保持相同的Geohash精度值(即长度)。
三、拓展与思考
上一节,在实现步骤<4>提到,筛选条件仅用了距离因子进行了限制。并最终选取距离最小的一个。筛选条件,是一个值得思考和深究的地方。它极大影响了添加“地理信息”的准确率。
此文的任务需求是:增添行政区划数据,一个POI在一个级别也就只有一个行政区划信息。所以,找距离最小的一个,在这个任务场景下,仅靠距离因素限制,一般问题也不大,往往准确率也能达标。 但如果增加的是“热点地名,商圈”等地理信息。比如,王府井,理想国大厦,望京,中关村等。仅利用距离因子作为限制条件,现实情况下,准确率常常是不达标的。那么,应该怎么做呢?
如果是热点地名与商圈的地理信息补充,可以考虑,利用目标POI点与其周边格子所处位置,进行限制。比如,必须呈包含目标POI点的态势时,才能添加。怎么定义包含态势呢?这个读者可以自行定义与实现。这里举2个实例:
a、目标点处于中心位,其余8个格子都包含“望京”这样一个商圈地理信息。这是典型的包含态势,目标POI点可增加商圈“望京”;
b、目标点上/下(南北),左/右(东西),对角线格子均具有相同的地理信息,这种也可视为呈包含态势( 大致如图2-1所呈现的样子)。
图2-1,展示了目标POI点以及周边8个格子的示意图。可以想象每个Geohash的格子都包含了一些地理信息。
为了提高添加的“地理信息”的准确度。总结一下,本人能想到的限制条件主要有以下3方面:
1、距离条件是基础,必须有距离限制;
2、目标POI点所处格子与欲添加地理信息所处格子的位置态势进行限制;
3、为目标POI点增加某一个地理信息,该地理信息在单个格子出现的次数,以及它被距离条件筛选后,总体出现的次数。
【注】某个地理信息出现的次数:可理解为有多少个POI具有该地理信息。
四、代码实现
代码实现,使用的是scala语言。Scala可以方便的调用Java语言的jar包。因此,你也可以理解为是Java实现的。这里有利用了Java的两个重要的jar包。
利用Spatial4j包计算两个经纬度之间的球面距离;利用ch.hsr.geohash包获取一个geohash周边8个网格(geohash)的方法
<dependency>
<groupId>org.locationtech.spatial4j</groupId>
<artifactId>spatial4j</artifactId>
<version>0.7</version>
</dependency>
<dependency>
<groupId>ch.hsr</groupId>
<artifactId>geohash</artifactId>
<version>1.3</version>
</dependency>
以上两个包都能计算Geohash值。Geohash的长度对应了不同的精度。长度与精度对照表如下(最长为12):
geohash码长度 | 宽度 | 高度 |
---|---|---|
1 | 5,009.4km | 4,992.6km |
2 | 1,252.3km | 624.1km |
3 | 156.5km | 156km |
4 | 39.1km | 19.5km |
5 | 4.9km | 4.9km |
6 | 1.2km | 609.4m |
7 | 152.9m | 152.4m |
8 | 38.2m | 19m |
9 | 4.8m | 4.8m |
10 | 1.2m | 59.5cm |
11 | 14.9cm | 14.9cm |
12 | 3.7cm | 1.9cm |
按照第二章解决方案的1~4的步骤实现。这里先要敲定距离阈值r,假定r=2公里,则Geohash的长度应选5(即4.9km,4.9km的格子)。由对照表可知如果选择Geohash长度为6(对应1.2km,0.6km),构造出的9宫格,是不满足需求的,会有漏掉满足距离目标POI点为2公里的行政区划POI点的。这是为什么,请大家自己思考吧。
先把行政区划数据和结果词表geohash_map词表文件的样例贴出:
//这里对行政区划POI做了信息抽取,直接是town-name city 经纬度,存放到town.txt文件中,具体格式如下:
舒庄乡 周口市 114.454095,33.509907
幸福乡 乐山市 103.89755,28.939625
张家塬镇 宝鸡市 107.117532,34.699135
大林乡 忻州市 112.723693,38.856616
穆店乡 淮安市 118.605614,32.917239
//由town.txt构建的Geohash词表,存放在geohash_map词表中,第一列是5位的Geohash值,后面是城镇信息,具体格式如下:
wscey: 万福镇|吉安市|114.885236,27.419279
ws4wq: 新亨镇|揭阳市|116.289072,23.624153
wqry3: 和川镇|临汾市|112.23623,36.264385
wt45m: 石鼻镇|南昌市|115.573624,28.726617
ybe87: 铁林街道|伊春市|128.833531,47.864312
wq3d9: 免古池乡|临夏回族自治州|103.42043,35.619691
步骤1: 利用行政区划POI构建geohash_map词表
/**
* @define 利用原始词表town.txt构建geohash_map词表.
* @param fpath
* @param output
* @param len
*/
def init_town_map(fpath: String, output: String, len: Int = 5): Unit = {
val geohash_map = scala.collection.mutable.Map[String, List[String]]()
Source.fromFile(fpath,"UTF-8").getLines().toList.filter(_.trim != "").foreach(line =>
{
val split_line = line.split("\t", -1)
if (split_line.size == 3) {
val town = split_line(0)
val city = split_line(1)
val loc = split_line(2).split(",", -1)
val geohash_code = get_geohash_code(split_line(2))
val tmplist = List[String](town + "|" + city + "|" + loc.mkString(","))
if (geohash_map.contains(geohash_code)) {
geohash_map(geohash_code) = geohash_map(geohash_code) ++ tmplist
} else {
geohash_map += (geohash_code -> tmplist)
}
}
})
val out = new PrintWriter(output)
for((k,v) <- geohash_map){
out.println(k+": " + v.mkString("\t"))
}
out.close()
}
/**
* @define 依据经纬度以及指定长度,计算Geohash值.默认长度指定为5.
* @param loc_str
* @param len
* @return
*/
def get_geohash_code(loc_str:String,len:Int = 5):String = {
val loc = loc_str.split(",",-1)
val lon = loc(0).toDouble
val lat = loc(1).toDouble
val geohash_code = GeohashUtils.encodeLatLon(lat, lon, len)
geohash_code
}
步骤2:这里给出了如何找出9宫格的Geohash值。代码实现时,不仅找到了9个方格的geohash,还给每个方案设定了标记值,标注方向。标记值与格子位置的对应关系如下图所示。有了格子相对目标POI点的方向标注,后续才可能实现第三节所说的依据“位置态势”进行限制。其中,目标POI所处的格子,方向标注是MY。
import ch.hsr.geohash.GeoHash
import org.locationtech.spatial4j.context.SpatialContext
import org.locationtech.spatial4j.distance.DistanceUtils
import org.locationtech.spatial4j.io.GeohashUtils
/**
* @define 包括自己一共会找到9个格子(涵盖自己和相邻的8个格子),分别用标
* 记"MY,N,NE,E,SE,S,SW,W,NW"标记出格子的方位,其中MS,是该点自己所处格子的标记.
* @param lon
* @param lat
* @return
*/
def find_nearby_geohash(lon:Double,lat:Double):Array[Tuple2[GeoHash,String]] = {
val nearby_town_array = ArrayBuffer[Tuple2[GeoHash,String]]()
try{
val geohash:GeoHash = GeoHash.withCharacterPrecision(lat,lon,5)
nearby_town_array += Tuple2(geohash,"MY")
val nearby_town = geohash.getAdjacent
//N, NE, E, SE, S, SW, W, NW
val direct_flag_list = "N,NE,E,SE,S,SW,W,NW".split(",",-1)
for(i <- 0 until nearby_town.size){
val geohash_item = nearby_town(i)
val direct_flag = direct_flag_list(i)
nearby_town_array += Tuple2(geohash_item,direct_flag)
}
}catch {
case e:Exception => {}
}
nearby_town_array.toArray
}
步骤3:利用步骤2得到的9个格子的Geohash值,查找构建的行政区划词表(town_geohash.map),找出每个格子对应的行政区划数据(名称和经纬度),并计算每个行政区划数据与目标POI点的距离。
//存放所有找到的行政区划数据(行政区划的一些信息值,存放为String类型,与目标POI的距离,Double类型)
val all_nearby_towns = ListBuffer[Tuple2[String,Double]]()
//9个格子的geohash值和方向标注均被保存在一个存放为Tuple2类型的数组中。遍历这个数组,获取每个格子中的行政区划数据(名称,城市,经纬度)。
val nearby_town:Array[Tuple2[GeoHash,String]] = find_nearby_geohash(lon,lat) //find_nearby_geohash 在上面步骤2实现了该方法
if(nearby_town.size > 0){
nearby_town.foreach(geohash_item => {
val geohash_code = geohash_item._1.toBase32
val direct_flag = geohash_item._2
if(geohash_map.contains(geohash_code)){
val nearby_town_list = geohash_map(geohash_code).map(town_item => {
var item = Tuple2[String,Double](town_item,10000.0)
val tmparr = town_item.split("\\|",-1)
if(tmparr.size == 3){
val town = tmparr(0)
val gcity = tmparr(1)
val loc2 = tmparr(2).split(",",-1)
if(city == "" ){
val distance = get_distance(Tuple2(lon,lat),Tuple2(loc2(0).toDouble,loc2(1).toDouble))
item = (town_item+"|"+nearby_geohash,distance)
}else{
if(city.startsWith(gcity) || gcity.startsWith(city)){
val distance = get_distance(Tuple2(lon,lat),Tuple2(loc2(0).toDouble,loc2(1).toDouble))
item = (town_item+"|"+nearby_geohash,distance)
}
}
}
item
}).filter(_._2 <= 2.0 ) //此处,直接将距离大于2公里的行政信息都已剔除了
if(nearby_town_list.size > 0){
all_nearby_towns ++= nearby_town_list
}
}
})
}
/**
* @define 提供一对经纬度坐标,计算两个点的球面距离
* @param loc1
* @param loc2
* @return
*/
def get_distance(loc1:Tuple2[Double,Double], loc2:Tuple2[Double,Double]):Double = {
val geo:SpatialContext = SpatialContext.GEO
val geo_shape = geo.getShapeFactory
val p1 = geo_shape.pointXY(loc1._1,loc1._2)
val p2 = geo_shape.pointXY(loc2._1,loc2._2)
val distance:Double = geo.calcDistance(p1,p2) * DistanceUtils.DEG_TO_KM
get_litpoint_level(distance,2) //单位:km,该函数仅是设定获取小数点后几位。
}
def get_litpoint_level(num:Double,level:Int):Double = {
val bg:BigDecimal = new BigDecimal(num)
bg.setScale(level, BigDecimal.ROUND_HALF_UP).doubleValue()
}
步骤4:取距离最小的作为添加的行政区划信息。这里的代码实现方式是:将所有存放行政区化信息的List,按照距离排序(升序)。然后,取第一个元素,即距离最小的那个行政区划信息。
//all_nearby_towns按照距离进行升序排序,步骤3的代码中已经限定了存放的元素都必须小于2公里。
//所以,这里没有重复限定2公里。都必定是<=2公里的元素
val sort_nearby_towns = all_nearby_towns.sortWith(_._2 < _._2)
val nearest_town = sort_nearby_towns.head //取第1个元素,作为添加的行政区划信息。
另外,这里认为,不存在同时有1个以上的行政区划的点,与目标POI点的距离一样。默认,最小值仅存在一个。因此,代码实现没有考虑上述极端情况。
写到这里,4个步骤均以实现完成了。拓展一节中提到的更多筛选限制的条件。其实,在步骤3或步骤4中均可增加代码实现。比如,上述步骤3中的代码实现,如果认真阅读,可以发现,代码实现中,多了一个城市的限定比较。行政区划的数据信息,它所归属的城市必须与目标POI所属城市相同,才能进入候选集。否则,无论远近,均不能作为候选集。