Python + ElasticSearch:轻松玩转跨越千年的两百三十万条地震数据

1 前言

2020年2月18日17时许,济南市长清区发生了4.1级地震,震源深度10千米。其后的两天里又连续发生了多次余震。一直以为,济南是一个lucky-bargee,无论是自然灾害,还是人祸战乱,几乎从没有伤害过她。但是,这次发生在长清区的地震动摇了我的观念。那么,济南在历史上究竟有没有发生过更高震级的地震呢?

带着这个疑问,我在网上搜集了古今中外230万条地震数据,借助于 Python 和 ElasticSearch,分析了全球和中国的地震分布,并对中国多个省区的历史地震数据做了对比分析。所有源码和示例数据已上传至GitHub,有兴趣的读者可以下载源码后,使用下载代码自行下载全部数据。

2 Python + ElasticSearch的环境搭建

2.1 安装和启动ElasticSearch

ElasticSearch是一个分布式、高扩展、高实时的搜索与数据分析引擎。它能很方便的使大量数据具有搜索、分析和探索的能力。充分利用ElasticSearch的水平伸缩性,能使数据在生产环境变得更有价值。

ElasticSearch采用的是NoSql数据库,其基本概念与传统的关系型数据库的概念有所不同。我们先了解一下这两个概念:

  • 文档
    NoSql数据库又叫做文档型数据库。文档就相当于关系型数据库的记录(行)。

  • 索引
    在关系数据库中的索引,是为了加速查询设置的一种数据结构。不同于关系数据库的索引,Elasticsearch会为每个字段创建这个含义的索引,而且Elasticsearch中的索引是透明的,因此Elasticsearch不再谈论这个含义的索引,而是赋予索引两个含义:

    • 名词:一个索引类似于传统关系数据库中的一个数据库,是一个存储关系型文档的地方
    • 动词:索引一个文档,就是存储一个文档到一个索引(名词)中以便它可以被检索和查询到,相当于关系数据库的insert

ElasticSearch是使用java编写的,安装ElasticSearch之前,首先要安装java运行环境。为了减轻电脑的负担,可以不安装JDK,只安装JRE即可。

安装完成后,需要设置环境变量。我安装的是jre1.8.0_241,安装路径在C:\Program Files\Java\,如果你安装的版本路和路径有所不同,请根据实际安装情况填写:

  • JAVAHOME:C:\Program Files\Java\jre1.8.0_241
  • CLASSPATH:.;%JAVA_HOME%\lib
  • PATH: %JAVA_HOME%\bin

环境变量设置好后,就可以安装ElasticSearch了。ElasticSearch安装很简单,从官方网站下载下载以后解压, 在其解压路径下的bin文件中运行elasticsearch.bat,即可启动ElasticSearch服务。比如,我的ElasticSearch解压路径是D:\Tools\elasticsearch-7.6.0,启动命令是这样的:

PS D:\Tools\elasticsearch-7.6.0\bin> .\elasticsearch.bat

2.3 安装Python的Elasticsearch客户端

万能的pip:

pip install elasticsearch

安装成功后,即可使用该客户端连接Elasticsearch服务器。

>>> from elasticsearch import Elasticsearch
>>> es = Elasticsearch()
>>> es.info()
{'name': 'LAPTOP-FN4A44A5', 'cluster_name': 'elasticsearch', 'cluster_uuid': 'FUBGxHB4QC2dMccTlAvphg', 'version': {'number': '7.6.0', 'build_flavor': 'default', 'build_type': 'zip', 'build_hash': '7f634e9f44834fbc12724506cc1da681b0c3b1e3', 'build_date': '2020-02-06T00:09:00.449973Z', 'build_snapshot': False, 'lucene_version': '8.4.0', 'minimum_wire_compatibility_version': '6.8.0', 'minimum_index_compatibility_version': '6.0.0-beta1'}, 'tagline': 'You Know, for Search'}

3 数据下载与解析

3.1 数据源

最初打算从中国地震局官方网站下载数据,后来发现地震局官网数据远没有全球地震历史数据查询网站数据丰富。该网站包含来自中国地震局(CEA)和美国地质勘探局(USGS)两个来源的数据。中国地震局(CEA)有感地震数据从2012年开始,而5级以上地震数据最早可以追溯到公元1000年。中国地震局(CEA)数据偏重中国境内地震信息,国外震级较小的已经去除。美国地质勘探局(USGS)数据始于1900年,偏重美国境内,在美国之外的国家,大抵只包含震级较大的地震数据。

3.2 下载与解析

全球地震历史数据查询网站的数据下载非常简单,无需注册,使用GET方法就可以轻松下载。URL有两个参数,dizhen_ly表示数据源,page表示页码。dizhen_ly=china表示下载中国地震局(CEA)数据,dizhen_ly=usa表示下载美国地质勘探局(USGS)数据。以下是数据下载与解析源码,下载后的数据保存为.csv文件。

import time, re, requests
from bs4 import BeautifulSoup

def Crawl_data(url, csv_file):
    '''抓取地震数据'''
    
    resp = requests.get(url)
    r = re.compile('查询到 (\d+) 条记录,分 (\d+) 页显示')
    pcount = int(r.findall(resp.text)[0][1]) # 获取总页数

    # 取得每页数据表格,并写到csv文件中
    with open(csv_file, 'w', encoding='utf-8') as fp:
        fp.write('发震时刻, 震级(M), 经度(°), 纬度(°), 深度(千米), 参考位置, \n')
        for page in range(1, pcount+1):
        #for page in range(2):
            print('第%d页/共%d页' % (page, pcount), '...', end='')
            try_count = 0
            resp = requests.get(url+'&page=%d'%page)
            while not resp.ok and try_count < 2:
                try_count += 1
                time.sleep(try_count*1)
                resp = requests.get(url+'&page=%d'%page)
            
            if not resp.ok:
                print('Error:', url+'&page=%d'%page)
                continue
            
            soup = BeautifulSoup(resp.text, 'lxml')
            for tr in soup.find_all('tr')[1:]:
                tds = tr.find_all('td')
                dt = tds[0].text
                level = tds[1].text
                lon = tds[2].text
                lat = tds[3].text
                deep = tds[4].text
                location = tds[5].find('a').text
                fp.write('%s, %s, %s, %s, %s, %s\n'%(dt, level, lon, lat, deep, location))
            print('Done')

调用Crawl_data()函数,给出下载地址和数据文件名,即可完成数据的下载和解析。

Crawl_data('http://ditu.92cha.com/dizhen.php?dizhen_ly=china', 'earthQuake_china.csv')
Crawl_data('http://ditu.92cha.com/dizhen.php?dizhen_ly=usa', 'earthQuake_usa.csv')

友情提示:抓取中国地震局数据大约需要1分钟,抓取美国地质勘探局数据则可能需要10个小时,甚至更长时间,请对此做好足够的心理准备,并妥善安排下载时间。

4 数据清洗与入库

4.1 数据清洗策略

由于数据的时间跨度超过了1000年,很多历史地震数据的日期、时间、地点等信息都不够精准,在处理时会导致异常。为此,我们约定:

  • 若数据缺少时间,则按00:00:00计
  • 若数据的日期不完整,缺少月份则按1月计,缺少日期,则按1日计
  • 若震级、经纬度、震源深度不能转为浮点数,则视为无效数据

4.2 创建索引(建库)

Elasticsearch功能很强,但很多概念搞得莫名其妙,最不能忍受的是,乱用索引(index)这个词。比如,关系型数据库中的建库或者建表操作,MongoDB中的创建集合(collection)操作,到了Elasticsearch这里,居然变成了创建索引。这还不算完,关系型数据库中的插入(insert)操作,到了Elasticsearch这里,竟然还是index操作。算了,不吐槽了,谁让人家功能强呢,有点脾气也是应该的嘛。

from elasticsearch import Elasticsearch, client

def create_index():
    """创建索引"""
    
    es = Elasticsearch()
    ic = client.IndicesClient(es)
    
    # 如果索引存在则删除
    try:
        ic.delete(index="earthquake")
    except:
        pass

    # 创建索引
    ic.create(
        index="earthquake", 
        body={
            "mappings": {
                "properties": {
                    "time":     {"type": "date"}, # 发震时间
                    "level":    {"type": "float"}, # 震级 
                    "geo":      {"type": "geo_point"}, # 地理位置
                    "deep":     {"type": "float"}, # 深度 
                    "location": {"type": "text"}, # 位置 
                    "source":   {"type": "keyword"} # 数据来源 
                }
            }
        }
    )

针对每一条地震数据,我们设计了6个属相项:时间、震级、经纬度、震源深度、地址、数据来源。调用create_index()函数,即可在Elasticsearch数据库中完成索引创建。请注意,创建索引之前,会先删除同名的索引。

4.3 数据清洗与入库

from datetime import datetime
from elasticsearch import Elasticsearch

def insert_doc(csv_file, source):
    """数据入库"""

    with open(csv_file, "r", encoding="utf-8") as fp:
        lines = fp.readlines()
    
    total = len(lines)-1 # 文件内数据总量
    success, failure = 0, 0 # 累计成功和失败数量
    section = 10000 # 分批插入,每批次数量
    
    rank = list(range(1, len(lines), section))
    rank.append(len(lines))
    for i in range(len(rank)-1):
        print(rank[i], rank[i+1])
        docs = []
        fail = 0 # 本批次失败数量
        for line in lines[rank[i]:rank[i+1]]:
            data = line.split(",")
            try:
               dt = datetime.strptime(data[0], "%Y-%m-%d %H:%M:%S").isoformat()
            except:
                try:
                    d, t = data[0].split()
                    yy, mm, dd = d.split('-')
                    if mm == '00':
                        mm = '01'
                    if dd == '00':
                        dd = '01'
                    
                    #print("Data Clearing:", data[0])
                    data[0] = '%s-%s-%s %s'%(yy, mm, dd, t)
                    dt = datetime.strptime(data[0], "%Y-%m-%d %H:%M:%S").isoformat()
                except:
                    print("Error:", i, data[0])
                    fail += 1
                    continue
            
            try:
                cmd = {"index":{"_index":"earthquake"}}
                doc = {
                    "time": dt,
                    "level": float(data[1]),
                    "geo": [float(data[2]), float(data[3])],
                    "deep": float(data[4]),
                    "location": data[5],
                    "source": source
                }
                docs.append(cmd)
                docs.append(doc)
            except:
                print("Error:", line)
                fail += 1
        
        es = Elasticsearch()
        ret = es.bulk(index='earthquake', body=docs)
        success += len(docs)/2 - fail
        failure += fail
        print("%s共计%d条数据,累计入库%d条,累计失败%d条"%(csv_file, total, success, failure))

下面两行代码,分别把下载下来的中国地震局(CEA)数据文件和美国地质勘探局(USGS)数据文件导入到Elasticsearch数据库中。共计约230万条记录,耗时大约3分钟。

insert_doc("earthQuake_china.csv", 'CEA')
insert_doc("earthQuake_usa.csv", 'USGS')

5 数据分析与可视化

5.1 数据分析与可视化源码

数据已经备好,激动人心的时刻终于开始了。直接上源码:

#!/usr/bin/env python
# coding:utf-8

from pyecharts import options as opts
from pyecharts.charts import Geo
from pyecharts.globals import ChartType
from datetime import datetime
from elasticsearch import Elasticsearch
import matplotlib.pyplot as plt

plt.rcParams['font.sans-serif'] = ['FangSong'] # 指定默认字体
plt.rcParams['axes.unicode_minus'] = False     # 解决保存图像时'-'显示为方块的问题

def get_data(level, year, source):
    '''获取地震等级不小于level的地震数据'''

    es = Elasticsearch()
    dt = datetime.strptime(str(year), '%Y').isoformat()
    condition = {
        'size': 0,
        'track_total_hits': True,
        'query': {
            'bool': {
                'must': [
                    {
                        'range': {
                            'level': {
                                'gte': level
                            }
                        }
                    },
                    {
                        'range': {
                            'time': {
                                'gt': dt
                            }
                        },
                    }
                ]
            }
        },
        'aggregations': {
            'heatmap': {
                'geohash_grid': {
                    'field': 'geo',
                    'precision': 5
                },
                'aggs': {
                    'centroid': { 
                        'geo_centroid': {
                            'field': 'geo'
                        }
                    }
                }
            }
        }
    }
    
    if source == 'CEA' or source == 'USGS':
        condition['query']['bool']['must'].append({'term':{'source':source}})
    
    return es.search(index='earthquake', body=condition)

def plot_heatmap(level, maptype, source, year=1900, cb=(0,10)):
    '''绘制地震热力图
    
    level   - 仅绘制不小于level等级的地震数据
    maptype - 地图类型:china|world
    source  - 数据源:CEA|USGS
    year    - year:起始年份
    cb      - ColorBar显示的最小值和最大值
    '''
    
    zone = '中国' if maptype == 'china' else '全球'
    subject = '公元%d年至今%s%d级以上地震热力图(%s)'%(year, zone, level, source)
    data = get_data(level, year, source)
    #print(data['hits']['total']['value'])
    
    c = Geo(init_opts={'width':'1700px', 'height':'800px'})
    c.add_schema(maptype=maptype)
    values = []
    for bucket in data['aggregations']['heatmap']['buckets']:
        c.add_coordinate(bucket['key'], bucket['centroid']['location']['lon'], bucket['centroid']['location']['lat'])
        values.append((bucket['key'], bucket['doc_count']))

    c.add(subject, values, type_=ChartType.HEATMAP)
    c.set_series_opts(label_opts=opts.LabelOpts(is_show=False))
    c.set_global_opts(
        visualmap_opts=opts.VisualMapOpts(min_=cb[0], max_=cb[1], is_calculable=True, orient='horizontal', pos_left='center'),
        title_opts=opts.TitleOpts(title='Geo-HeatMap'),
    )
    c.render('%s.html'%subject)
    
def top_10(year, source):
    """条件检索"""
    
    dt = datetime.strptime(str(year), '%Y').isoformat()
    condition = {
        'size': 10, 
        'query': {
            'bool': {
                'must': [
                    {
                        'range': {
                            'time': {
                                'gt': dt
                            }
                        },
                    },
                    {
                        'term': {
                            'source': source
                        }
                    }
                ]
            }
        }, 
        'sort': {
            'level': {
                'order': 'desc'
            }
        },
        'highlight': {
            'fields': {
                'time': {},
                'level': {},
                'location': {}
            }
        }
    }
    
    es = Elasticsearch()
    ret = es.search(index='earthquake', body=condition)
    
    result = list()
    for item in ret['hits']['hits']:
        result.append((item['_source']['time'], item['_source']['level'], item['_source']['location'].strip()))
    
    return result
    
def search_by_condition(location, level, year=1900, source='CEA', size=200):
    """条件检索"""
    
    dt = datetime.strptime(str(year), '%Y').isoformat()
    condition = {
        'size': size, 
        'query': {
            'bool': {
                'must': [
                    {
                        'range': {
                            'level': {
                                'gte': level
                            }
                        }
                    },
                    {
                        'range': {
                            'time': {
                                'gt': dt
                            }
                        },
                    },
                    {
                        'match_phrase': {
                            'location': {
                                'query': location,
                                'slop': 0
                            }
                        }
                    },
                    {
                        'term': {
                            'source': source
                        }
                    }
                ]
            }
        }, 
        'sort': {
            'time': {
                'order': 'desc'
            }
        },
        'highlight': {
            'fields': {
                'time': {},
                'level': {},
                'location': {}
            }
        }
    }
    
    es = Elasticsearch()
    ret = es.search(index='earthquake', body=condition)
    
    result = list()
    for item in ret['hits']['hits']:
        result.append((item['_source']['time'], item['_source']['level'], item['_source']['location'].strip()))
    
    return result
    
def plot_bar(city_list, level_list, year=1900, source='CEA', size=200):
    """绘制城市分级地震柱状图"""
    
    title = '公元%d年至今中国部分省区地震次数柱状图(%s)'%(year, source)
    fig, ax = plt.subplots()
    fig.set_size_inches(12, 6)
    
    for level in level_list:
        data = list()
        for city in city_list:
            data.append(len(search_by_condition(city, level, year=year, source=source, size=size)))
        #print(level, data)
        ax.bar(city_list, data, 0.35, label='%d级及以上'%level)
    
    ax.legend(loc='upper left')
    ax.set_ylabel('地震次数')
    ax.set_title(title)
    fig.savefig('%s.png'%title)

if __name__ == '__main__':
    # 公元1000年至今全球5级以上地震热力图(CEA)
    plot_heatmap(5, 'world', source='CEA', year=1000, cb=(0,10))
    
    # 公元1900年至今全球7级以上地震热力图(USGS)
    plot_heatmap(7, 'world', source='USGS', year=1900, cb=(0,5))
    
    # 公元1900年至今全球7级以上地震热力图(CEA)
    plot_heatmap(7, 'world', source='CEA', year=1900, cb=(0,5))
    
    # 公元1000年至今中国5级以上地震热力图(CEA)
    plot_heatmap(5, 'china', source='CEA', year=1000, cb=(0,20))
    
    # 公元1000年至今中国7级以上地震热力图(CEA)
    plot_heatmap(7, 'china', source='CEA', year=1000, cb=(0,3))
    
    # 公元1900年至今中国7级以上地震热力图(CEA)
    plot_heatmap(7, 'china', source='CEA', year=1900, cb=(0,1))
    
    # 公元1900年至今中国部分省区地震次数柱状图(CEA)
    city_list = ['北京', '上海', '广东', '江苏', '浙江', '山东', '台湾', '河南', '安徽', '云南', '贵州', '四川', '湖北', '陕西', '新疆', '河北', '甘肃', '江西', '吉林', '辽宁']
    level_list = [6, 7]
    plot_bar(city_list, level_list, year=1900, source='CEA', size=2000)
    
    # 最强地震TOP10
    for year, source in [(1900, 'USGS'), (1900, 'CEA'), (1000, 'CEA')]:
        top = top_10(year=year, source=source)
        print('自公元%d年以来最强地震TOP10(%s)'%(year, source))
        for i, item in enumerate(top):
            print('|%d|%s|%.1f|%s|'%((i+1), *item))
        print('----------------------------------')
    
    # 公元1000年以来济南地震史
    print('公元1000年以来济南地震史:')
    res = search_by_condition('济南', 0, 1000)
    for i, item in enumerate(res):
        print('%d. %s %.1f %s'%((i+1), *item))

5.2 公元1000年至今全球5级以上地震热力图(CEA)

这是基于中国地震局(CEA)数据绘制的公元1000年至今全球5级以上地震热力图,红色表示5级以上地震等于或超过10次。
在这里插入图片描述

5.3 公元1900年至今全球7级以上地震热力图(USGS)

这是基于美国地质勘探局(USGS)数据绘制的公元1900年至今全球7级以上地震热力图,红色表示7级以上地震等于或超过5次。
在这里插入图片描述

5.4 公元1900年至今全球7级以上地震热力图(CEA)

这是基于中国地震局(CEA)数据绘制的公元1900年至今全球7级以上地震热力图,红色表示7级以上地震等于或超过5次。和上一张图相比,CEA的数据明显比USGS少很多。
在这里插入图片描述

5.5 公元1000年至今中国5级以上地震热力图(CEA)

这是基于中国地震局(CEA)数据绘制的公元1000年至今中国5级以上地震热力图,红色表示5级以上地震等于或超过20次。
在这里插入图片描述

5.6 公元1000年至今中国7级以上地震热力图(CEA)

这是基于中国地震局(CEA)数据绘制的公元1000年至今中国7级以上地震热力图,红色表示7级以上地震等于或超过3次。
在这里插入图片描述

5.7 公元1900年至今中国7级以上地震热力图(CEA)

这是基于中国地震局(CEA)数据绘制的公元1900年至今中国7级以上地震热力图,红色表示7级以上地震等于或超过1次。
在这里插入图片描述

5.8 公元1900年至今中国部分省区地震次数柱状图(CEA)

这是从公元1900年至今中国部分省区地震次数柱状图。百余年来,台湾居然有近50次超过7级的地震,真得是触目惊心啊!
在这里插入图片描述

5.9 最强地震TOP10(USGS, 公元1900年至今)

No. 时间 震级 地点
1 1960-05-22T19:11:20 9.5 Bio-Bio(智利大地震)
2 1964-03-28T03:36:16 9.2 Southern Alaska
3 2011-03-11T05:46:24 9.1 near the east coast of Honshu
4 2004-12-26T00:58:53 9.1 off the west coast of northern Sumatra
5 1952-11-04T16:58:30 9.0 off the east coast of the Kamchatka Peninsula
6 2010-02-27T06:34:11 8.8 offshore Bio-Bio
7 1965-02-04T05:01:22 8.7 Rat Islands
8 2012-04-11T08:38:36 8.6 off the west coast of northern Sumatra
9 2005-03-28T16:09:36 8.6 northern Sumatra
10 1957-03-09T14:22:33 8.6 Andreanof Islands

5.10 最强地震TOP10(CEA, 公元1000年至今)

No. 时间 震级 地点
1 2012-04-11T16:38:36 8.6 苏门答腊北部附近海域
2 1950-08-15T22:09:34 8.6 西藏察隅、墨脱>Ⅹ
3 1920-12-16T20:05:53 8.5 宁夏海原Ⅻ
4 1668-07-25T00:00:00 8.5 山东郯城≥Ⅺ
5 1902-08-22T11:00:00 8.3 新疆阿图什北>Ⅹ
6 1556-02-02T00:00:00 8.3 陕西华县Ⅺ
7 2017-09-08T12:49:15 8.2 墨西哥沿岸近海
8 2015-09-17T06:54:31 8.2 智利中部沿岸近海
9 2013-05-24T13:44:49 8.2 鄂霍次克海
10 2012-04-11T18:43:12 8.2 苏门答腊北部附近海域

CEA的数据看起来有点不科学,排名第一的,居然不是1960年的智利大地震。好在,CEA数据让我们看到了中国历史上的两次大地震:山东郯城大地震和陕西华县大地震,也算是找回了一点颜面吧。

6 结语

6.1 济南地震史

是时候回答我的问题了:济南在历史上究竟有没有发生过更高震级的地震呢?检索公元1000年以来地震地点包含“济南”的所有地震数据:

# 公元1000年以来济南地震史
print(公元1000年以来济南地震史)
res = search_by_condition('济南', 0, 1000)
for i, item in enumerate(res):
print(i+1, *item)

结果显示,最近1000年来,济南竟然真的只有刚刚发生的两次地震!

公元1000年以来济南地震史:

  1. 2020-02-20T04:44:34 3.1 山东济南市长清区
  2. 2020-02-18T17:07:16 4.1 山东济南市长清区

6.2 郯城地震

历史上,发生在山东的地震虽然屈指可数,但有史以来我国东部破坏最为强烈的地震却发生在山东。公元1668年7月25日,山东南部发生了一次旷古未有特大的地震,震级为8.5级,极震区位于山东省郯城、临沭、莒县一带,震中位置为北纬34.8°、东经118.5°(临沭县干沟渊村),极震区烈度达Ⅻ度。由于极震区大部分位于郯城县境内,故称为郯城地震。这次地震是我国大陆东部板块内部一次最强烈的地震,造成了重大的人口伤亡和经济损失。

蒲松龄先生在《聊斋》中如此描述这次大地震:康熙七年六月十七日戌时,地大震。余适客稷下(齐国古都,今临淄,距离我老家很近),方与表兄李笃之对烛饮。忽闻有声如雷,自东南来,向西北去。众骇异,不解其故。俄而几案摆簸,酒杯倾覆,屋梁椽柱,错折有声。相顾失色。久之,方知地震,各疾趋出。见楼阁房舍,仆而复起,墙倾屋塌之声,与儿啼女号,喧如鼎沸。人眩晕不能立,坐地上随地转侧。河水倾泼丈余,鸡鸣犬吠满城中。逾一时许始稍定。视街上,则男女裸体相聚,竞相告语,并忘其未衣也。

6.3 智利大地震

1960年5月21日下午3时,智利发生9.5级地震。从这一天到5月30日,该国连续遭受数次地震袭击,地震期间,6座死火山重新喷发,3座新火山出现。5月21日的9.5级大地震造成了20世纪最大的一次海啸。这是有仪器记录以来最大的一次地震。地震造成智利2000多人死亡。几天之后,地震的能量穿过太平洋,在太平洋西岸掀起了海啸,又给日本和菲律宾的东部沿海地区造成了严重的损害。

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