聚类概念

  • 属于无监督学习(无标签)
  • 顾名思义,就是将相似的东西分到一组
  • 难点在于如何评估聚类效果以及调整参数

基本概念

  • K代表最后需要得到的簇个数(比如二分类K=2)
  • 一个簇的质心计算:采用均值,即每个特征向量取平均值
  • 距离度量:经常使用欧几里得距离余弦相似度(高维特征最好先执行一次标准化)
  • 优化目标:实现$min\sum_{i=1}^{k}\sum_{x∈C_i}dist(C_i,x)^2$

优缺点

优点:

  • 算法简单、快速
  • 适合常规数据集(就是一坨一坨的那种,没有特定形状)

缺点:

  • K值难以确定(一般是遍历K值取Inertia的拐点)
  • 算法复杂度与样本呈线性关系(一次迭代)
  • 很难发现任意形状的簇,比如二维特征的样本,一个圆环套一个球的形状KMeans就很难检测出来。

image-20230327102339790

算法流程

  • 首先随机选取K个样本作为K个簇的中心(在KMeans++中,使用最大距离进行初始化)

    • KMeans++逐个选取K个簇中心,且离其它簇中心越远的样本点越有可能被选为下一个簇中心
  • 将每个样本分配到K个簇中,具体选择哪个簇取决于该样本和K个簇的距离,选择距离最近的簇(分配样本)
  • 此时所有样本都分配到对应的簇,在每个簇中更新簇中心。新的簇中心采用简单的均值计算(更新簇中心)
  • 回到第二步再次分配样本,直到达到迭代次数上限损失指标小于某个阈值(损失指标有inertia/轮廓系数)

KMeans代码实现

import numpy as np
class KMeans:
    def __init__(self,data,num_clusters):
        self.data = data
        self.num_clusters = num_clusters
        
    def train(self,max_iterations):
        # 1.choose k centers
        centroids = KMeans.centroids_init(self.data,self.num_clusters)
        # 2.begin train
        num_examples = self.data.shape[0]
        closest_centroids_ids = np.empty((num_examples,1))
        for _ in range(max_iterations):
            # 3.get closest center id
            closest_centroids_ids = KMeans.centroids_find_closest(self.data,centroids)
            # 4.update k centers
            centroids = KMeans.centroids_compute(self.data,closest_centroids_ids,self.num_clusters)
        return centroids,closest_centroids_ids
    
    @staticmethod
    def centroids_init(self,data,num_clusters):
        num_examples = data.shape[0]
        random_ids = np.random.permutation(num_examples)
        centroids = data[random_ids[:num_clusters],:]
        return centroids
    
    @staticmethod
    def centroids_find_closest(self,data,entroids):
        num_examples = data.shape[0]
        num_centroids = centroids.shape[0]
        closest_centroids_ids = np.zeros((num_examlpes,1))
        for example_index in range(num_examples):
            distance = np.zeros(num_centroids,1)
            for centroid_index in range(num_centroids):
                distance_diff = data[example_index,:] - centroids[centroid_index,:]
                distance[centroid_index] = np.sum(distance_diff**2)
            closest_centroids_ids[example_index] = np.argmin(distance)
        return closest_centroids_ids
    
    @staticmethod
    def centroids_compute(self.data,closest_centroids_ids,self.num_clusters):
        num_features = data.shape[1]
        centroids = np.zeros((num_clusters,num_features))
        for centroid_id in range(num_clusters):
            closest_ids = closest_centroids_ids == centroid_id
            centroids[centroid_ids] = np.mean(data[closest_ids.flatten(),:],axis=0)
        return centroids

sklearn

接下来介绍sklearn库中的KMeans实现.

文档见sklearn.cluster.KMeans — scikit-learn 1.2.2 documentation

Attributes

image-20230327110345259

__init__

  • n_clusters : 簇个数K
  • random_state : 随机数种子
  • init : K个簇中心的初始化方式
  • n_init : 算法执行次数,取结果最好的一次
  • max_iter : 单次算法迭代次数(分配簇/更新簇)
from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=2,random_state=0,init='k-means++',n_init=10,max_iter=300)

fit

kmeans.fit(X) # input X
kmeans.cluster_centers_ # center coordinate
kmeans.label_ # every sample's label
kmeans.inertia # loss

pred

kmeans.predict(New_X)
kmeans.label_

tranform

Transform(): Method using these calculated parameters apply the transformation to a particular dataset.

返回值shape : (num_examples,num_clusters) 即每个样本和每个类的距离 通过这个方法也可以得到predict结果(argmin)

from sklearn.preprocessing import StandardScaler
sc = StandardScaler()
sc.fit_tranform(X_train)
sc.tranform(X_test) # 一定是tranform 保证train和test使用同一个转化标准

可视化

# draw cluster points
def plot_data(feature,snr,label):
    df = pd.DataFrame(dict(x=snr, y=feature, color=label))
    fig, ax = plt.subplots()
    colors = {0:'red', 1:'blue'}
    ax.scatter(df['x'], df['y'], c=df['color'].apply(lambda x: colors[x]))
    plt.show()

# draw cluster centers
def plot_centriods(snr,centroids,weights=None,circle_color='g',cross_color='k'):
    if weights is not None:
        centroids = centroids[weights>weights.max()/10]
    plt.scatter(snr,centroids[:],
               marker='o',s=30,linewidths=8,
               color=circle_color,zorder=10,alpha=0.9)
    plt.scatter(snr,centroids[:],
               marker='x',s=50,linewidths=50,
               color=circle_color,zorder=11,alpha=1)

评估指标

inertia指标

inertia : 每个样本距离质心的距离

score方法的结果是inertia的负值(绝对值越大分数越小)

kmeans.inertia_
X_dist = kmeans.transform(X)
np.sum(X_dist[np.arange(len(X_dist)),kmeans.labels_]**2) # inertia计算
kmeans.score(X) == -1*kmeans.inertia_

轮廓系数

某个样本的轮廓系数定义为:

$$ s = \frac {disMean_{out} - disMean_{in}}{max(disMean_{out}-disMean_{in})} $$

其中$disMean_{in}$为该点与本类其他点的平均距离,$disMean_{out}$为该点与非本类点的平均距离。该值取值范围为[−1,1], 越接近1则说明分类越优秀。在sklearn中函数 silhouette_score() 计算所有点的平均轮廓系数,而silhouette_samples()返回每个点的轮廓系数。

K值选取(拐点法)

kmeans_per_k = [KMeans(n_clusters=k).fit(X) for k in range(1,10)]
inertias = [model.inertia_ for model in kmeans_per_k]
# draw fig
plt.figure(figsize=(8,4))
plt.plot(range(1,10),inertias,'bo-')
plt.show()

image-20230327114805748

半监督学习

KMeans也可以用于半监督学习

比如在一个少样本训练场景下,相比于随机选取50个训练样本打标加入训练这种方式,可以先使用聚类挑出50个簇中心,每个簇选取一个样本作为典型样本;用这些样本打标训练,最后测试出来的acc会更高。除此之外,还可以通过标签传播(同一个簇内的标签一致)的方式扩大训练集。

OFD(Open Fixed-layout Document)

OFD 是开放版式文档(Open Fixed-layout Document )的英文缩写,是我国国家版式文档格式标准,通俗来说,也有人称这格式为国产PDF。但是在很多方面的性能优于PDF的同类文档。OFD也逐渐开始在电子发票、电子公文、电子证照等等的领域中应用。

JJJYmmm目前有把OFD转换成txt的需求,但是网上大多是OFD<--->PDF的方法。所以决定自己动手,丰衣足食(bushi)

OFD格式

OFD其实一个压缩文件,如果我们尝试解压,将会得到如下的目录结构。

image-20230330111607378

  • OFD.xml里面一般是文档的描述信息,比如创建时间、最迟修改时间、作者等等

1

  • Doc_0里保存的是具体文档信息,我们主要关注Pages文件夹中的内容Content.xml,这里面保存的是文档主体内容,也就是我们需要提取的.
  • 观察Content.xml里的内容,可以发现 : 字符在\<ofd:TextCode\>字段中,而\<ofd:TextCode\>嵌套在\<ofd:TextObject\>对象里,众多TextObject封装在\<ofd:PageBlock\>,外层还有\<ofd:PageBlock\>/\<ofd:Layer\>等字段

1

Python脚本处理xml

接下来可以编写Python脚本来处理xml了!!!

首先就是解压OFD,直接当成普通压缩文件解压即可.

def unzip_file(zip_path, unzip_path=None):
    """
    :param zip_path: ofd格式文件路径
    :param unzip_path: 解压后的文件存放目录
    :return: unzip_path
    """
    if not unzip_path:
        unzip_path = zip_path.split('.')[0]
    with zipfile.ZipFile(zip_path, 'r') as f:
        for file in f.namelist():
            f.extract(file, path=unzip_path)
    return unzip_path

解析XML则借鉴之前FasterRCNN源码中的解析方法.因为TextObjcect对象和目标检测中的Bounding Box类似,有很多实例,故思路是一致的,即通过递归方法解析XML为dict,遇到TextObject对象时value值用列表保存.代码如下:

def parse_xml_to_dict(prefix_ofd,xml):
    """
    将xml文件解析成字典形式,参考tensorflow的recursive_parse_xml_to_dict
    Args:
        xml: xml tree obtained by parsing XML file contents using lxml.etree

    Returns:
        Python dictionary holding XML contents.
    """

    if len(xml) == 0:  # 遍历到底层,直接返回tag对应的信息
        return {xml.tag: xml.text}

    result = {}
    for child in xml:
        child_result = parse_xml_to_dict(prefix_ofd,child)  # 递归遍历标签信息
        if child.tag != prefix_ofd + 'TextObject':
            result[child.tag] = child_result[child.tag]
        else:
            if child.tag not in result:  # 因为TextObject可能有多个,所以需要放入列表里
                result[child.tag] = []
            result[child.tag].append(child_result[child.tag])
    return {xml.tag: result}
这里存在一个 prefix_ofd 前缀,具体来说,这取决于xml第一个字段Page中的xmlns:ofd字段,在使用etree进行解析时,键值中的ofd:会被替换成xmlns:ofd字段,具体原理JJJYmmm暂时也不太清楚,是在debug时发现的()

将XML解析成字典后,就可以遍历字典取出我们想要的结果了~具体代码如下,还是比较简单的

pages_path = f"{file_path}/Doc_0/Pages"
text = ''
for page_path in os.listdir(pages_path):
    context_path = os.path.join(pages_path,page_path,'Content.xml')
    with open(context_path,'r',encoding='utf-8') as f:
        _text = f.read()
        xml = etree.fromstring(_text)
        prefix_ofd = '{http://www.ofdspec.org/2016}'
        # xml to dict
        data = parse_xml_to_dict(prefix_ofd,xml)
        # 遍历TextObject
        for textobj in data[prefix_ofd+'Page'][prefix_ofd+'Content'][prefix_ofd+'Layer'][prefix_ofd+'PageBlock'][prefix_ofd+'PageBlock'][prefix_ofd+'PageBlock'][prefix_ofd+'TextObject']:
            # print(textobj[prefix_ofd + 'TextCode'])
            text+= textobj[prefix_ofd+'TextCode']

效果展示

以一份政府公文为例,首先用OFD查看器查看内容.

image-20230330113837269

执行脚本,demo如下图.

image-20230330113926344

最后保存得到的txt如下.可以发现文章内容都提取出来力.

image-20230330114005974

TODO

  • 第一个问题是最后提取出来的txt没有换行符.这个问题在于OFD的xml文件里本来就没有保存换行符,是直接通过坐标进行渲染的(详见TextObject的Boundary字段).
  • 第二个小问题是没有封装成批量处理脚本,但是这个问题不大(单纯是不想写 咕了)