事件抽取(EventExtraction)是一种面向非结构化文本或半结构化数据的信息抽取(InformationExtraction)任务,与传统面向知识图谱的实体、关系、属性等信息抽取有所不同的是,事件抽取抽取的是"事件",即某些事物在时空范围内的运动。在ACE(Automatic Content Extraction)测评会议中,事件被描述成:"在特定时间内,发生的,同时有参与者的,存在状态变化的事情。例如,"李主任将在明天举办的大会上发言"中描述了具体的事件,这样的句子也被称为事件提及,包含了"李主任"-"大会"-"发言"这些事件要素。而事件抽取的目的,正是从非结构化、半结构化的事件提及中将结构化的事件要素提取出来从而进行分析。事件抽取是不少任务的前置模块,对于事理图谱构建、情报分析、新闻摘要、自动问答等任务均有着重要的作用,事件抽取的准确程度也会显著地影响后续任务的效果。
一般来说,根据是否有明确的、事先定义好的事件模式(或事理图谱schema),可以将事件抽取分为封闭域事件抽取(Close-domainInformationExtraction,也有称为限定域事件抽取)与开放域事件抽取(Open-domainInformation Extraction)。封闭域事件抽取的主要任务包括:
根据上述不同事件抽取任务得到的数据,可以明确地描述一个具体的事件。一个完整的封闭域事件抽取系统,应当以联合模型(JointModel)或抽取流水线(Pipeline)的形式得到上述的内容,或者至少得到触发词、论元。以一个具体的例子展示封闭域事件抽取:"詹姆斯枪击了弗兰克"中,包含攻击类型的事件,其触发词为枪击,事件论元包含詹姆斯和弗兰克,前者的论元角色是攻击者,后者的论元角色是被攻击者。
而开放域事件抽取与封闭式事件抽取不同,没有明确的事件模式或schema,因此构建开放域事件抽取不拘泥于精确地将事件具体要素进行精确抽取,其主要目的一般是通过聚类、文本语义分割等无监督手段,在开放的文本数据中分析、检测出事件,以供后续的分析。开放域事件抽取在舆情感知、舆情分析、情报分析、股市情绪调研等应用中有着重要的作用。开放域事件抽取的主流任务基本可分为:
由于开放域事件抽取并没有像 ACE那样公认、权威的任务范式,因此上述分类可能根据实际应用场景、数据集等条件产生变动。但一般来说,开放域事件抽取的粒度较粗,一般不会对具体的触发词类型、论元角色层面的信息进行抽取。
本文中主要对封闭域事件抽取进行简述。
事件抽取的评价指标主要为P、R、F1。其中P为准确率(Precision),P=正确抽取结果数/抽取结果总数,R为召回率(Recall),R=正确抽取结果数/需抽取结果总数,F1=P*R/(P+R)。对于自动抽取系统或将事件抽取作为信息处理流水线的一部分时,应尽量提高F1指标,以降低抽取错误造成后续步骤的错误累积;在有人工干预的事件抽取系统中,应在保证一定F1指标的基础上,尽量提升召回率指标,以尽量确保抽取时不漏抽。
在评测事件抽取模型或系统时,一般使用上述指标分别对事件模式中的各部分子任务分别进行评价,例如在相关论文中一般会同时汇报TI(Trigger Identification,触发词识别)、TC(TriggerClassification,触发词分类,即事件类型分类)、AI(ArgumentIdentification,论元识别)、AC(ArgumentClassification,论元分类,即论元角色分类)四个子任务的 P、R、F。
ACE05是事件抽取任务最常用的基准数据集,包含了英语、阿拉伯语和汉语的精标注信息抽取数据,囊括了事件抽取中几个最常见的子任务。ACE05遵循 LDC(Linguistic DataConsortium)的用户协议,需要注册为LDC会员才能下载与使用。
对于特定的事件抽取任务也会有相关数据集,例如针对舆情分析、公关、政治事件检测的CrisisLexT26、BlackLivesMatterU、SoSItalyT4等社交网络数据集,针对突发事件、自然灾害的ChileEarthquakeT1等数据集均可以作为事件抽取数据集在特定任务上使用。
对于特定语言(尤其是中文)的事件抽取任务,除了 ACE05之外,比较知名的还有上海大学构建的CEC 中文突发事件语料库、CEEC中文环境突发事件语料库、百度DuEE数据集等。
除了上述数据集外,还有KBP数据集(TAC Knowledge BasePopulation)等知识图谱相关数据集也提供了事件抽取任务相关的标注。另外部分开源项目、信息抽取竞赛也会提供对应的事件抽取数据集,然而相较于其它信息抽取任务,事件抽取的基准数据集,尤其是中文语种的基准数据集依然稀缺,这也促使了少样本(few-shot)事件抽取、基于远程监督(DistantlySupervised)等事件抽取等方法的发展。
事件抽取任务早在 20 世纪 50年代便有研究者开始研究,传统的事件抽取方法一般以相关领域专家手工编写规则、指定模板匹配等方式实现。随着网络信息的爆炸式增加,传统的方法开始无法胜任新的需求,基于统计的机器学习方法、深度学习模型等新的技术应运而生,大幅提高了事件抽取任务的效果。本节将介绍上述几种方法的典型代表。
而近年来,还出现了利用外部知识(如背景知识、知识图谱)增强事件抽取效果的工作,以及少样本事件抽取的新任务范式,基于问答的事件抽取新模型,以及针对特定数据(如日志数据、生物数据等)事件抽取的相关工作,将在后续发展趋势章节中介绍。
基于模式匹配的事件抽取方法一般需要领域专家人工构建规则与模板,这些规则与模板通常会以词典、正则、语法树等形式进行匹配。典型的事件抽取专家系统(如AutoSlog、PALKA)以及后续使用部分统计或学习方法来改善规则的系统(如CRYSTAL、AutoSlog-ST 等)都是基于这种形式实现的抽取。
上图为一个典型的模式匹配规则(SBV-VOB),当句子中仅含有单个主语和宾语,且谓语不是系动词或助动词时,则谓语动词一般是触发词。这类基于模式匹配的方法通常包含构建与抽取两个步骤,即事先在语料上发掘出规则,然后将规则应用到新的待抽取文本上进行匹配。如下图所示:
基于模式匹配的事件抽取方法虽然时间久远且限制较多,但它有着很好的可解释性,以及对精标注数据的数据量要求不高,即使在近期也有相关研究在推进,如GenPAM等系统。相比于经典的专家系统,这些较新的系统有一定的能力自动从通用语料和领域语料中自动挖掘或生成对应的模式,在一定程度上可以降低人力成本。但一般来说此类方法的准确率依然受限。
由于基于模式匹配的方法通常需要大量人力资源,且效果不佳,特别是在迁移到新的领域数据上时需要重新挖掘模式,因此基于统计机器学习的方法在20 世纪后逐渐替代了传统的模式匹配方法。
上图展示了经典的机器学习事件抽取流程,其中事件装配(EventAssembling)一般是对分类结果的后处理,如事件合并、聚类等。比较典型的统计机器学习方法包括最大熵模型(MaximumEntropy Model)、支持向量机(Support VectorMachine)、条件随机场(Conditional RandomField)等,一般来说此类工作的特点是作者会精心根据数据集和模型选择特征(如POS、bigram等),并将问题视为分类问题,例如AAAI 2002《A Maximum Entropy Approach to Information Extraction fromSemi-Structured and FreeText》以"指示词"、POS、在两个指示词中间的动词等特征进行结合,送入最大熵模型进行分类以得到事件类型。
对于触发词识别、论元识别等,一般使用 CRF等方法将问题建模为序列标注任务,如 COLING 2012《Joint Modeling ofTrigger Identification and Event Type Determination in Chinese EventExtraction》利用马尔可夫随机场进行序列标注,得到了很好的效果。
上图为经典工作《Complex event extraction at PubMedscale》的事件抽取流水线,其中步骤 A 为句法树 parse,步骤 B 为利用 CRF和已有特征进行命名实体识别,步骤 C为利用分类模型对每个词单独分类从而识别触发词类型,步骤 D为在触发词和实体间使用 SVM构造多标签分类模型进行连边检测,最后在步骤E组合成为一个事件。
如何选择或构建合适的特征,即特征工程对机器学习方法的效果有着决定性的影响;以及统计机器学习方法通常需要大规模的精标语料库,且容易收到语料类别不均衡、长尾数据等情况的影响;并且难以融入外部的先验知识,因此在近年深度学习技术高速发展的浪潮中逐渐被替代。
深度学习是机器学习技术的一个分支,通过深层神经网络解决了传统机器学习方法学习能力有限,无法通过持续增加数据量提升学习到的知识总量的问题,并有一定的自动表征能力,解放了设计机器学习模型时设计与构建特征的难题。在近年来随着算力和数据的共同发展,深度学习在自然语言处理等领域得到了广泛的研究与应用,最新的事件抽取方法大都是基于深度学习模型所构建的。
基于深度学习的事件抽取模型五花八门,并随着深度学习模型的发展而提出更多、更新的方法。例如,可以与TextCNN 一样使用卷积神经网络(CNN,Convolutional NeuralNetwork)来提取文本的特征,然后送入分类模型进行分类,或进行序列标注;也可以利用长短记忆神经网络(LSTM,LongShort Term Memorynetworks)的链式网络结构对句子中各个词的上下文关系进行建模,以提升效果;亦或是使用最新的BERT等预训练语言模型,在大规模预训练的基础之上再对事件抽取任务进行微调。
例如,ACL 2015《Event Extraction via Dynamic Multi-PoolingConvolutional NeuralNetworks》提出了一种典型的深度学习事件抽取模型,其论元分类的模型结构如图所示:
首先使用词嵌入(WordEmbedding),得到词表中单词的表示向量;当输入一段文本时,将文本的实体的表示向量从词嵌入中查出,作为词汇特征表示(LexicalLevel Feature Representation);然后对整个句子使用 CNN +最大池化的方式,得到句子的特征表示(Sentence LevelFeature);最后将实体的词汇表示和句子特征拼在一起,进行分类并输出。通过这样简单的建模方式,经过监督训练后,就可以在ACE2005 数据集上达到当时最好的效果,足以体现深度学习技术的强悍。
再例如,ACL 2019《Exploring Pre-trained Language Models for EventExtraction andGeneration》提出了一个两阶段的深度学习事件抽取模型,其抽取模型的结构如图所示:
本文使用了预训练语言模型 BERT来作为文本表示模型。文中的抽取模型分为两步:触发词抽取(左)与论元抽取(右)。首先通过BERT的序列标注方式,对句子中的每个词进行分类,得到各个词能作为某一类触发词的可能性;然后将各个触发词与原句字一同送入论元抽取模型中,对每个词执行二分类,即可得到单个词作为指定触发词论元的概率,通过这种方式解决了一个词同时作为多个事件的论元的重叠(overlap)问题。
除了上述基于传统深度学习方法外,还有人使用图神经网络(Graph NeuralNetwork)的形式来建模事件抽取问题(如 AAAI 21《GATE: Graph AttentionTransformer Encoder for Cross-lingual Relation and EventExtraction》),同时还有各式各样更加有趣的神经网络模型不断被应用到事件抽取任务中;在可以预见的将来,深度学习会继续统治事件抽取这一课题,并在事件抽取基准数据集上不断刷新效果。
随着深度学习的发展,事件抽取技术越发成熟,但同时也暴露出了一些问题,如:数据集大小不足、多样性不足;小语种、低资源语种缺乏数据等。因此,近年来有研究者开始针对在数据受限的情况下,通过引入外部知识、小样本(few-shot)抽取等手段,或者利用问答技术来提升事件抽取模型的泛用性与效果。
为了弥补事件抽取数据集受限的实时,不少研究者尝试引入外部知识以增强事件抽取的效果。例如,ACL2016《Leveraging FrameNet to Improve Automatic EventDetection》引入了额外的语言资源库FrameNet来自动生成符合ACE要求的带标签新数据,从而实现对ACE2005数据集的扩充,并提升模型效果;ACL2017《Automatically Labeled Data Generation for Large Scale EventExtraction》引入 Freebase 知识库,将其中的 CVT 映射为事件类型、CVT实例映射为事件实例、CVT 值映射为论元、CVT 角色映射为论元的角色,并通过FrameNet 过滤噪声等。
随着知识库构建、预训练语言模型的增强,引入外部知识的手段将会更加丰富,对于增强事件抽取将会有更进一步的帮助。
小样本学习(few-shotlearning,或少样本学习)与传统深度学习模式有所区别,通常形式为一个非常小的支撑集(supportset)提供标签信息,通过度量学习等方式让模型得到一定的泛化能力。例如,ACL2020 NUSE worksthop《Extensively Matching for Few-shot Learning EventDetection》提出了一种用于 Event Detection的小样本学习方法,它将任务设定为(n+1)-way k-shot,即给定一个包含 n类的支撑集(以及一个额外的 NULL集来标识没有事件的情况),其中每一类只有k个数据。在训练时,该文设定了损失函数:
\[L=L_{\text {query }}+\beta \hat{L}_{\text {intra }}+\gamma\hat{L}_{\text {inter }}\]
其中 L_intra 为类内损失,即让同一个聚类的类内距离越小越好:
\[L_{i n t r a}=\sum_{i=1}^{N} \sum_{k=1}^{K} \sum_{j=k+1}^{K}\operatorname{mse}\left(v_{i}^{j}, v_{i}^{k}\right)\]
L_inter 为类间损失,让不同聚类距离(即类心原型距离)越远越好:
\[L_{i n t e r}=1-\sum_{i=1}^{N} \sum_{j=i+1}^{N}\operatorname{cosine}\left(c_{i}, c_{j}\right)\]
L_query 为query 损失,让 query 能尽量与正确对应的原型距离更近:
\[\begin{gathered}L_{q u e r y}(x, S)=-\log P(y=t \mid x, S) \\P\left(y=t_{i} \mid x, S\right)=\frac{\exp \left(-d\left(f(q, p),\mathbf{c}^{i}\right)\right)}{\sum_{j=1}^{N} \exp \left(-d\left(f(q, p),\mathbf{c}^{j}\right)\right)}\end{gathered}\]
经过对L的训练后,模型在 5-way 5-shot 和 10-way 10-shot的设定上得到了良好的效果。
除了这篇文章之外,还有不少的研究者也在研究如何用少量的数据来训练好一个事件抽取模型,甚至不用数据(zero-shot)通过迁移学习等手段来让模型有良好的效果。随着小样本学习、元学习等深度学习技术的进步,小样本事件抽取也将由此收益,从而有更好的效果与更广阔的应用前景。
针对传统深度学习事件抽取模型无法对标签中的语义进行建模、无法捕获标签到触发词或论元的交互、泛化能力低的问题,研究者们提出了利用问答的形式来增强事件抽取的效果,近年来这种方案有多篇顶会论文进行研究。
较为典型的是 EMNLP 2020《Event Extraction as Multi-turn QuestionAnswering》,将事件抽取问题以下图所示的问答的形式向机器提出:
该文使用了一种多轮问答的框架用于解决事件抽取,可以充分利用触发词、事件类型和论元之间的交互信息,同时利用多轮问答策略以捕捉相同事件类型中不同论元角色之间的依赖。其模型框架图如下所示:
左边为触发词识别,利用机器阅读理解(MRC,Machine ReadingComprehension)的形式,通过提问从文本中取出触发词;得到触发词后,将其作为提示语,送入中间的触发词分类模型进行分类,同样是提出问题(问题定义为The trigger word is
除了传统自然语言处理问题外,还有其它领域的应用可以从事件抽取技术中获益。例如,针对分布式系统产生的海量日志进行事件抽取,可以从中构建错误事理图谱,或者从中挖掘不同节点的错误依存关系等模式。在日志分析系统中的部分模块,例如日志segmentation(切分)与parsing,也可以从开放域事件抽取的事件分割等技术得到启发,更加精确地对不同节点产生的日志进行划分。日志事件抽取样例如下图所示:
例如,ISC 2018《Event Extraction from Streaming SystemLogs》提出了在流系统日志上执行事件抽取,通过一定的领域知识(例如IP地址的格式、日志日期等可以使用正则表示),及模板匹配等手段,可以得到对应的日志事件的结构化信息。
除了用在日志数据上外,事件抽取技术也可以用在各类生物、医学等领域的数据上,事件抽取技术的发展不仅利于自然语言处理领域,也能惠及其它学科的信息化进程。
Enhancing Chinese Pre-trained Language Model via HeterogeneousLinguistics Graph 一文是我和组内同学合作的工作,录用于 ACL 2022主会。代码已在
这篇论文提出了一种用于表达中文字-词-句语言学结构关系的异质图(HeterogeneousLinguistics Graph,HLG)。并利用图神经网络建模,在该HLG异质图上实施多步信息传播(Multi-StepInformation Propagation,MSIP)以在预训练语言模型的微调阶段训练神经网络的参数。使用这样的HLG建模中文自然语言的结构可以自然而有效地引入分词结构化信息,从而提升原生预训练语言模型在中文上的效果,实验证明该方法在多个基准数据集上得到了稳定的提升。同时,相比起前人发表在ACL2020年的工作[1](MWA),此论文使用的MSIP和HLG建模在训练、推理速度上有着明显的优势,在不降低性能的情况下提升了约7倍的训练与推理速度。
近年来,以BERT为代表的预训练语言模型方法在各个NLP任务中得到了广泛的应用。典型的预训练语言模型应用方法可以归结为预训练-微调两阶段模式,即先通过在大规模无标注语料库上进行无监督、自监督预训练,然后通过监督训练迁移到具体的下游任务中使用。而针对中文自然语言处理,研究者们提出了各类适配中文语言特性的预训练语言模型,如ERNIE[2]、Glyce[3]等,尽可能利用中文本身的一些性质(例如中文分词、中文字形等)来提升预训练任务的效果。Li等人[1]基于向预训练语言模型融入中文分词的动机提出了MWA模型,试图向原生的预训练语言模型中融入词汇级别特征,与其它专注于预训练的工作不同的是,MWA是在微调的阶段来进行外部信息的融入的,如下图所示:
这样的方式有个好处,可以避免重新预训练所带来的高昂代价,并且实验证明了这样的方法可以在多个中文自然语言处理任务上对原生的BERT等模型带来有效的提升。
MWA是利用一种非标准形式的分段式attention方法,将中文的分词切割信息应用到字符表示产生的attention权重上,对同一个词中的不同字进行mix-pooling聚合,从而让字的attention权重强行在词的级别上进行对齐。这样的设计有效地融入了分词的分段式的结构信息,但也带来了一些新的问题:由于需要逐词、逐样本地计算attention的聚合,会导致attention模型中原本可以向量化、并行化的标准矩阵运算变成需要各自运算、无法并行的高负载运算,并且这样的算子无法利用cudnn原语的加速,也无法享受当今非常重要的深度学习计算加速硬件(如GPU、TPU等)带来的速度提升。
此外,MWA使用了简单的mix-pooling来汇聚字级别的attention权重到词级别,这样简单的pooling方式会导致一部分分词结构上的信息损失,没有很好地反应字到词、字到字的层级化交互形式,而是以平均值的形式将字词进行了统一。
最后,MWA提出可以使用多个分词器,融合多个分词器带来的分词信息,以进一步提升模型的效果,但MWA中使用了非常原始的线性加权的形式,对不同分词器产生的MWA字符表示进行加权求和,这样的形式不仅没有体现出不同分词器所带来的分词纠错的效果,还会产生训练参数的膨胀。因此,作者希望重新思考MWA带来的效果提升与随之产生的副作用,试图以一种更加自然的方式来建模相同的中文语言学结构信息,同时避免上述提到的问题。
受到MWA和多图集成(Multi GraphEnsemble)相关工作的启发,作者以“去噪”这一动机为核心,构建了中文语言学结构异质图(HLG)。在MWA中,作者提出了使用更多的分词器,会得到更好的效果;然而,无上限地加更多的分词信息难道能持续地带来性能提升吗?未必。当引入更多分词器的同时,也会引入更多的分词错误信息,即噪音。这些噪音信息会影响模型的训练效果,带来一定的副作用;如何让正确的分词结构在模型中起到更大的影响力,让错误的分词信息在模型中产生的影响被尽可能忽略,是构建HLG时所考虑的重点。
从模型集成(ModelEnsemble)考虑,各个已经训练好的分词器是良好的学习者(well-learner),它们各自产生的结果可以假设为大部分正确而小部分错误。因此,可以以模型集成的观点将它们产生的结果合在一起,体现出“少数服从多数”的投票效果,即如果有更多的分词器认为某个词A应当分出来,那么就应当认为这是更加可信的结论,而少数几个分出不同结果的分词器,则在词A的切分上被认为是不那么可信的。在图(Graph)的性质上看,就是让这些正确的分词节点的桥接中心性(betweennesscentrality)更大,这些节点在图的信息传递过程中起到的效果就越大。如下图所示:
以此为动机,作者设计了以字、词、句三个层次的节点构成的HLG,整体的结构如下图所示:
在HLG中,不同分词器产生的不同的词会产生不同的词节点,而在相同位置分出的相同的词会作为同一个词节点;由于一句话以不同的方式切割会自然地产生不同的语义,因此每个分词器分割的句子都作为一个单独的节点存在,分词器分出的词会与对应的句子节点相连。
HLG的构图方法在实质上就满足了前面提到的去噪的动机。以上图中“西山”节点,和“西”、“山”节点为例,前者(西山)有两个分词器支撑这个分词结果,而后者(西、山)只有一个分词器支撑,前者产生的节点在图中的度数会比后者更高。
得到HLG之后,需要使用图神经网络对这个图进行建模,而图神经网络通常处理的是只有一种节点类型的同构图(HomogeneousGraph),而HLG是有着多种节点类型、多种连边类型的异质图(HeterogeneousGraph)。因此,论文中使用了一种“多步”的信息传播方式,使用多个GCN层来控制不同层级的信息传播,从而实现了对HLG的建模。如下图所示:
整个信息传播过程的输入
但是,由于句节点的数量(与使用的分词器数量相同)会远远小于词节点和字节点的数量,因此将分词结构表示到句子节点后很难再具象化回字级别,阻碍了信息在图中的传播。这一点在模型训练中会体现为难以训练、效果降低等情况,因此,为了降低这种负面影响,作者引入了ResNet[4]的Skip-Connection,以残差连接的形式在归纳化和具象化过程中相同层级的节点间建立了通路,如下图所示:
由此,MSIP可以对HLG进行建模,从而在具体任务的微调过程中对模型参数进行学习训练。
与MWA相比,HLG的主要区别是,增加了句子节点,并将信息聚合与分发的方式进行了调整,从对attention权重的分段pooling改成了对层级图的GCN。而它们的输入、输出,以及融入的外部知识实际上都是一致的。下图说明了MWA和HLG在文本表示信息和分词结构信息聚合-分发过程中的异同:
论文对提出的模型在多个预训练语言下游任务基准数据集上进行了实验验证,结果如下:
与原生的预训练语言模型相比,HLG带来了稳定的提升;与MWA相比,HLG的实验效果也并不逊色。而在训练和推理效率方面,HLG可以说是一骑绝尘,甩开MWA一大截:
速度上基本上有着7倍以上的提升。对前面提到的“去噪”的动机,作者也通过引入更多分词器的方式进行了验证:
从1个分词器向上提升分词器数量的同时,会得到更多的词节点(新的分词器分出了不同的词),而效果也有微幅提升;引入更多的分词器时,增加的新的词节点的数量开始逐渐下降(由于加入的新的分词器分出的词与已有分词器的大体相同),而带来的性能相对提升也在逐渐降低;引入5个或超过5个分词器,带来的性能提升基本上没有了,甚至可能会出现效果衰退的情况,可能是由于带来了过多的噪声。作者在权衡使用多分词器引入的噪声、提升的效果和增加的预处理开销后,最终还是只使用了3个分词器。
这篇论文的贡献点可以归结为几块: 1.对于MWA提出的在预训练模型微调过程中引入新的模块,从而引入外部知识的做法,作者将其总结为了一种强化模块(enhancementmodule)的适配器(adapter),这样的方法可能在其它领域也能发挥作用; 2.作者提出了HLG来表示中文的分词的结构,并且可以在引入多个分词器的情况下体现出一定的去噪效果。同时,作者以MSIP的方法,成功用图神经网络对HLG这种异质图进行了建模;3.实验结果表明,这篇论文提出的HLG的方法与MWA带来的模型性能提升不分伯仲,但相比于MWA,HLG节省了至少一半的模型参数量,并且得益于标准的运算模式,HLG的训练、推理速度比MWA快了约7倍以上。
这篇论文的代码已经开源,可以在
1 | from flask import Flask |
在调试时,直接用 python3 app.py
运行,一切正常。但是在使用 gunicorn 切换到生产环境时,使用gunicorn -w 1 -b 0.0.0.0:5000 app:app
时,却出现了apscheduler 的后台任务不运行的情况;
直接运行 python3 app.py
自然是以顺序执行,并且程序的__name__
是 __main__
,自然scheduler.run()
和 app.run()
会正常执行;但经过查阅资料和接口文档得知,gunicorn 是将 app:app 即app.py 中的 app 对象(Flask 实例)作为入口的,而此时程序的__name__
是 app
,因此scheduler.run()
直接就不执行了。
所以,对于单进程(多线程)的任务,直接将 scheduler.run()
放在 __name__
判断条件外,就能正常执行了:1
2
3
4
5
6
7
8
9from flask import Flask
from apscheduler.schedulers.background import BackgroundScheduler
app = Flask(__name__)
scheduler = BackgroundScheduler()
scheduler.run()
if __name__ == '__main__':
app.run(debug=True)
当然 app.run()
还是要放在判断中的,只在调试模式下运行。
近年来,预训练语言模型在NLP领域展现出了强大的能力而被广泛采用,成为了解决NLP问题的“银弹”。借助大规模数据集、以Transformer为代表的深度神经网络模型、以及设计好的自监督预训练(pre-train)任务,预训练语言模型展现出了强大的泛化能力,经过微调(fine-tune)后在各个下游任务中得到了优秀的成果,其强大性能让人对其学习到的内容产生了兴趣:预训练语言模型是否真的在预训练过程中学习到了“知识”呢?
最近也有工作提出了prompt范式,通过构建的prompt语句,将特定的下游任务转换为预训练语言模型的预训练任务(如MaskLanguageModel)从而得到结果,这种形式有点类似于从知识库中使用一定的查询语句找出对应的答案。因此,有研究者开始探索是否能将预训练语言模型作为“知识库”使用。本文针对自然语言处理中的预训练语言模型如何通过预训练建模“知识”、推导知识,以及预训练语言模型是否能作为知识库这三方面进行了简单的论文导读。
预训练语言模型的其实可以追溯到静态词向量的研究。从最初的One-Hot向量、词袋模型、tf-idf到后来的Word2Vec[1]、FastText[2,3]等方法,研究者通过建模token的统计概率信息,或者上下文的统计概率信息来对语言进行统计学建模。
例如,上图为word2vec提出的两种经典方法CBOW与Skip-gram,它们分别通过“使用周围的词来预测中心词”以及“使用中心词来预测周围的词”这两种方法,使用滑动窗口的方法对窗口大小长度的序列进行上下文建模,最终在训练过程中通过梯度下降的方法优化句子中每个词的词嵌入(embedding)。相比于最初的One-Hot向量表示、词袋模型、tf-idf等传统统计学方法,word2vec等静态词向量方法能够更好地考虑到词的上下文语义信息,同时可以减少传统方法遇到的维度灾难问题。但是,这种静态的词嵌入表示方法也有不少的缺点,最典型的问题是词与词嵌入是一对一的表示,无法正确表示在不同上下文中出现的一词多义信息,即便到了后来研究者提出了一些效果更好的模型(Glove[4])也没能解决这种静态词向量的固有问题。
为了解决上述静态词嵌入表示的问题,研究者提出了基于语言模型嵌入(Embeddingfrom Language Models,ELMo)的方法[5],使用长短时记忆神经网络(LSTM)分别对文本的正向和反向进行语言模型建模:
通过语言模型任务进行预训练后,迁移到其它NLP任务中进行微调,在多个benchmark数据集上都得到了显著的提升:
同时,作者也通过实验对ELMo的消岐能力进行了分析,发现ELMo能够有效地区分多义词。自此以ELMo为代表的各种预训练语言模型开始不断出现,并不断提升各个NLP子任务的效果。
2017年Google提出了用于机器翻译任务的Transformer[6],Transformer基于多头自注意力机制实现,解决了之前LSTM类型模型无法很好处理长距依赖的问题并且更易于进行并行运算,在预训练语言模型领域也展现出了强大的能力,之后的预训练语言模型代表作BERT[7]、GPT[8]均是基于Transformer模型构建的。
Google于2018提出的BERT是预训练语言模型的经典之作。BERT由多层TransformerEncoder模块堆叠构成,作者为其设置了两种预训练任务:Masked LanguageModel(MLM)与Next SentencePrediction(NSP),采用两阶段预训练-微调范式,如下图所示:
其中,MLM来自于完形填空任务(Cloze),将一句完整的话中间的某些token抹去,让模型通过上下文来还原该token;NSP为句子对匹配任务,将两个句子拼接后传入模型中,让模型判断这两个句子是否在原语料中为连续关系。得益于这两个预训练任务,BERT在token层面与sentence层面都能通过大规模的无标注语料进行自监督训练,从而获得优秀的预训练模型参数,在迁移至下游NLP任务微调时,可以取得非常好的效果。
在BERT的基础上,研究者们对预训练语言模型的可能性进行了进一步的探索:有使用更大的预训练数据集的、有定义更合理的预训练任务的、以及还有各种尝试引入外部知识强化预训练语言模型的(如K-BERT[9]、thu-ERNIE[10]等)、还有引入特定语种的语言特性的(如baidu-ERNIE[11]、BERTwwm[12]等)、以及最近出现的通过提示(prompt)指导预训练语言模型从而提升效果减少训练数据的工作。邱锡鹏老师等对预训练语言模型进行了详尽的综述[13],下表展示了部分典型的预训练语言模型的模型架构、预训练任务、语料、参数量等信息:
THUNLP实验室也对预训练模型的过去、现在及将来进行了总结与展望[14],下图展示了部分典型预训练语言模型的“族谱”:
在预训练语言模型的训练过程中,模型通过自监督学习任务在非常大规模的语料库上进行了训练,除了学习到了语言模型之外,是否也能通过学习捕获到一定的规律,从而掌握文本中所蕴涵的事实类知识呢?如果预训练语言模型能掌握一些知识,应该如何从模型中把需要的知识查询出来呢?研究者们对此进行了探索性的研究。
此工作[15]由Facebook完成,发表在EMNLP2019。作者从MLM预训练任务出发,认为以BERT为代表的预训练语言模型可能学习到了事实类的知识(以主谓宾三元组形式存在)。例如,给定一句话“但丁于1265年出生在[MASK]”,通过MLM可以让模型从隐式表示中找出被mask掉的内容是什么。如果MLM模型成功将被隐去的内容还原,就相当于预训练语言模型包含了这条知识(但丁,出生于,佛罗伦萨)。相比之下,在传统的知识图谱上查询此知识需要先将原文本进行信息抽取构建三元组存储在知识库中,然后再构建查询,这些步骤都需要非常复杂的NLPpipeline,可能会产生错误积累从而影响效果。由此,作者设定了探针测试实验,探索预训练语言模型作为知识库的能力和潜力。下图为作者设定的两种查询事实类知识的方式(通过知识库查询以及通过预训练语言模型预测):
作者将设定的探针测试命名为LAMA(LAnguage Model Analysis)Probe,用来检测语言模型中包含了多少的事实类与常识类的知识。作者收集了GoogleRE等知识源,通过构造模板将知识三元组构造成模型能接收的完形填空的形式。例如,GoogleRE中包含“placeof birth”关系的数据,作者定义了“[S] was born in[O]”这样的模板用于填充。同理,对于常识类的知识,作者也定义了类似的模板,设定包含常识类三元组的数据将宾语mask掉,例如对于“CapableOf”(有...能力)的常识类数据,作者构造为“[S]can [O]”。下表展示了部分作者构造的数据形式:
通过上述的操作,并对数据进行筛选(排除掉不符合MLM范式的多token类型的数据等)后整合得到了最终的LAMA探针测试数据集。
为了评估预训练语言模型在LAMA上的效果,作者设定了几个baseline用于对比:
下表展示了使用不同模型(包括ELMo、TransformerXL、BERT以及设定的baseline模型)在LAMA探针实验中得到的结果:
由表中的LM大列可以看到,预训练语言模型确实能重构部分的事实类与常识类知识。可以看到最后一列(Bl,即BERT-large)的效果相较于其它LM-based的方法普遍较好,说明BERT-large相比于ELMo、TransformerXL等预训练语言模型包含了更多的事实类与常识类的知识,作者也推测了这可能与BERT-large预训练的数据量大也有关系。当然,这样的无监督MLM得到的结果还是比不上有监督的特定方法,尤其在SQuAD上与DrQAbaseline的差距还相当大。
此工作主要是探索性质的,作者也没有对于预训练语言模型在预训练阶段捕获知识的能力进行详尽的测试(可以通过在LAMA数据集上预训练或continuouslearning实现),而是着重探究了已经训练好的预训练语言模型中包含的事实类或常识类知识,并且从文章结构上看作者更加偏向于如何构造和分析LAMA这样的探针测试。从结果上看,虽然指标并不高,但可以说明预训练语言模型是有一定从大规模语料中提炼这些知识的能力的。但此文的限制也相当多,例如需要人工精心构建的模板、提前将不合适的类型或数据筛去等。
此工作[16]由Google提出,发表于EMNLP2020。此文使用了Google自家的T5预训练语言模型[17]作为基础模型。T5模型将所有NLP任务都归结转化为了text-to-text的任务,包括相似度等任务也是以seq2seq的方式生成的结果。而传统的MLM任务在T5中以类似SpanBoundary Detection的形式存在,如下图所示:
在一般的问答任务中,通常会提供一个问题与一段包含问题答案的文档,通过模型在文档中找到问题的答案。而此工作基于T5text-to-text的范式,以“闭卷”(closed-book)的形式在没有对应答案的上下文的情况下直接向模型输入问题以获取对应的答案。这样的问题设定可以说是在考验预训练语言模型在fine-tune的过程中能学到什么,以及考验模型能存储住多少知识。
此文直接使用了QA任务中常用的几个数据集:NQ、WebQuestion和TriviaQA。在这几个数据上,作者使用T5模型进行text-to-text的微调,将问题做为输入并将字面量答案作为预测目标。进行fine-tune后,在测试集上进行评估,得到结果如下表所示:
可以看到,T5通过此方法在“闭卷”问答中可以得到和“开卷”问答模型相当的结果,这说明通过fine-tune是可以将“知识”输入预训练语言模型并存储的。
此文的结论与LAMA类似,都是通过MLM或者text-to-textQA这些具体的任务探索了预训练语言模型存储知识的能力。而此文在QA任务数据集上能取得与“开卷”模型相当的SOTA效果,更是证明了将预训练语言模型作为知识库的潜力。这样端到端通过预训练语言模型在QA任务数据集上微调的形式可以避免复杂的QApipeline设计,其效果也许还有进一步提升的空间。
此文[18]通过设定多种探针任务,针对“预训练语言模型究竟能捕获到什么信息”这一设问进行了验证。作者为了验证不同的预训练语言模型在不同的“知识”形式、不同“知识推理”上下文情况下的能力,提出了如下几个任务:
所有的探测任务都以选择题的形式输入模型,如上表所示,Example列中列出了各个任务的具体示例,Human列中列出了人类在测评对应任务中的表现,Setup列中的字段表示该任务会以怎样的形式进行设定。此文使用了两种设定:MC-MLM- 多选完形填空,适用于答案集较少的情况;MC-QA -多选问答题,适用于答案和问题差别较大且更为复杂的情况。
此文对BERT、BERT-wwm、RoBERTa三种模型的base或者large版本进行了测评,针对前面提到的8种任务分别进行了fine-tuning和定量计算。最终,作者将不同模型、不同任务的完成情况总结为一张表格:
表格中的“勾”表示该模型在对应任务中有着较高的准确率,“半勾”表示有着一定的准确率,未打钩则表示该模型在对应任务中无法得到明显的效果。
从结果上看,预训练语言模型是可以在部分上述设定的任务中取得一定效果的。较为突出的是RoBERTaLarge模型,体现出了较强的能力。但是,在Always-Never任务、百科类推理任务和多跳推理任务中,没有任何一个模型能得到有效的效果。总体上看,在作者的任务设定下,预训练语言模型在知识推理中得到的结果差强人意,其效果与模型和任务组织形式有着明显的相关性。也许在更大规模的预训练语言模型,或更符合预训练语言模型的推理任务设定下,可以让效果更加明显。
此文[19]是软件所韩先培老师组发表在ACL2021的工作。作者对设定的“将预训练语言模型作为知识库使用”这一前提进行了探索性实验,主要围绕着prompt范式下从预训练语言模型中获取知识的性能与效果来源进行了实验与分析。
作者根据现有工作,将通过提示语从预训练语言模型中获取知识的方式分为了三大类:
现有工作通过这些提示语的组织形式可以得到良好的效果,作者对它们良好性能的来源提出了质疑,并设计实验分别对这几种通过提示语获取预训练语言模型知识的方式进行了分析。
如上文所述,作者针对不同的提示语构建方式分别定义了几组实验。
作者使用LAMA与WIKI-UNI两个分布不同的数据集,使用相同的提示语通过MLM进行知识获取,如下图所示:
在图(a)中,可以看到LAMA与WIKI-UNI的答案分布完全不一致,但在图(b)中,作者使用相同的提示语就会得到相似的答案分布,这说明了这种基于MLM的知识抽取形式的效果更加依赖于提示语的设计。后续作者还进行了定量的计算,也支持这个结论。
前人工作发现了在构建提示语时,可以通过一些示例来引导MLM的填充,并提升模型的性能。作者对此在LAMA数据集上构建了测试实验,将示例的实体进行同类别替换。
实验结果显示,在加入示例后,整体知识抽取的效果得到了提升,但如果是将已有的实例的实体替换为同样类别的实体,并不能给模型带来更好的效果。因此,可以得出结论:通过向提示语中加入示例,可以提升模型预测类型的能力,但不能提升模型得到具体的答案实体的效果。
如前面的例子所示:“Jobs lives in California. [SEP] Jobs was born in[MASK]”这样的提示语实际上泄露了答案:California。作者发现这样的现象后,在LAMA上构建实验,将上下文中的答案也进行遮掩,以排除潜在的答案泄露问题。
实验结果如上表所示,在遮罩掉上下文的答案之后,仍然可以获得一定的效果提升。作者提出猜想,可能是把上下文中的答案遮罩之后,模型仍然可以通过MLM的形式重建上下文中的答案,从而造成隐式的答案泄露。为了证明这个猜想,作者将数据集根据是否可以根据上下文重建遮罩答案划分成了两组,如下表所示:
可以发现,在上下文无法重建答案时,根据上下文构建提示语并不能带来很大的效果提升;而如果上下文可以重建答案,则无论是否将泄露的答案遮罩掉,这样的上下文提示语都能带来较大的效果提升。这样的结果也说明了基于上下文构建提示语的知识抽取的优良效果,很大程度上是依赖于显式或者隐式的答案泄露。
根据上面的实验,作者也得出了总体的结论:
基于提示语的知识抽取方法的效果会受到提示语偏差的影响;基于示例的类比知识抽取主要是依赖示例中对应实体的类型的指导提升效果;基于上下文的知识抽取的效果提升主要是依赖上下文中可能存在的答案泄露。
作者通过探索性实验发现,与预训练语言模型知识获取准确性相关的主要因素是提示语的构建,包括提示语偏差、类别指导和答案泄露几种非预期的行为导致了预训练语言模型作为知识库的性能提升,因此在目前的情况下,还不能claim“预训练语言模型可以替代知识库”这一结论。
在本文介绍的这几篇论文中,可以发现现在研究者们仍然是将预训练语言模型作为黑盒进行研究的,主要通过构建不同的探针任务来经验性的判定预训练语言模型学习知识、获取知识的手段与效果。如果后续能对预训练语言模型的机制进行更具体的探讨,以及对fine-tune或prompt等获取知识的方法进行更精细的建模,可能会得到更加可信、更加无偏的结果,这样才能进一步研究或探讨将预训练语言模型代替知识库的可能性。
此论文《Open Domain Question Answering Using Early Fusion ofKnowledge Bases and Text》发表于 EMNLP 2018。
对于开放域问答问题,作者试图将与问题有关的 Wikipedia 和 Freebase的知识结合起来构成一个融合子图,然后把开放域问答问题转换为在这个融合子图上的节点分类问题,是一篇典型的早期融合的工作。如下图的左右两部分所示,该文章要点有两处:(1)如何对知识库和文本进行联合构图;(2)如何在图上执行节点分类获得问题的答案。
将整个Wikipedia与Freebase进行联合构图是不现实的,因此作者先分别在知识库找出与问题相关的部分,然后通过不断在文本库中检索文本相关文本,加入融合子图中。
(1)从知识库构建问题相关子图:首先通过实体链指从问题
(2)文本信息节点:首先通过DrQA的带权词袋模型对文本库进行句子级别的检索,得到Top5与问题相关的文档,然后根据问题
其中
在上一步中,已经获得了包含知识库节点、文档节点、文档到实体连边的与问题
(1)初始化节点表示:对于知识库中的实体节点,赋予定长表示向量:
(2)异构融合子图的更新:由于前一步得到的图是异构的,需要根据问题
在问题节点的引导下,GRAFT-Net会以类似PageRank的形式,将问题节点的表示向整个异构融合子图中传播,最终得到融合了问题表示、实体表示、文本表示的异构融合子图中每个节点的表示
上图展示了该工作的主要模型,可以看到与基于子图嵌入的知识库问答一文类似,也是主要分成了两个部分:问题的文本表示,与答案候选集的子图表示。
对于问题的文本,此论文使用了与TextCNN类似的方式,使用卷积神经网络对问题的词嵌入进行滑动卷积与池化,从而得到问题的文本表示:\[\mathbf{x}_{j}^{(i)}=\mathbf{h}\left(\mathbf{W}^{(i)}\left[\mathbf{w}_{j-s}^{\top}\ldots \mathbf{w}_{j}^{\top} \ldots\mathbf{w}_{j+s}^{\top}\right]^{\top}+\mathbf{b}^{(i)}\right) \\\label{chap9:equ:mccnn}\mathbf{f}_{i}(q)=\max _{j=1, \ldots,n}\left\{\mathbf{x}_{j}^{(i)}\right\}\]
其中,
对于答案候选集,作者设定了三种特征:
(1)答案路径:对于从问题中的实体节点到答案节点的路径的表示,此论文采用了和《基于子图嵌入的知识库问答》中路径表示一样的方法:\[\mathbf{g}_{1}(a)=\frac{1}{\left\|\mathbf{u}_{p}(a)\right\|_{1}}\mathbf{W}_{p} \mathbf{u}_{p}(a)\]
其中,\(\mathbf{u}_{p}(a)\)为路径上每一个关系的稀疏向量表示,
(2)答案上下文(Context):该文将与答案相邻一跳的实体和关系称为答案的上下文,使用同样的方法进行嵌入表示:\[\mathbf{g}_{2}(a)=\frac{1}{\left\|\mathbf{u}_{c}(a)\right\|_{1}}\mathbf{W}_{c} \mathbf{u}_{c}(a)\]
其中,
(3)答案类型:作者认为,类型信息对于知识库问答十分重要,可以根据问题直接限定到答案的类型。例如当问题中有“Where”的时候,答案也应有很大的可能是与“location”相关的类型。因此,作者对答案的类型进行了与前文类似的表示:\[\mathbf{g}_{3}(a)=\frac{1}{\left\|\mathbf{u}_{t}(a)\right\|_{1}}\mathbf{W}_{t} \mathbf{u}_{t}(a)\]
如果答案是属性值,作者会将答案的类型定义为它的数值类型(如浮点数、字符串、日期等)。
获得上述三种答案候选的特征后,作者将这些特征与问题的表示进行联合打分计算相似度:\[S(q, a)=\underbrace{\mathbf{f}_{1}(q)^{\top} \mathbf{g}_{1}(a)}_{\text{answer path }}+\underbrace{\mathbf{f}_{2}(q)^{\top}\mathbf{g}_{2}(a)}_{\text {answer context}}+\underbrace{\mathbf{f}_{3}(q)^{\top} \mathbf{g}_{3}(a)}_{\text{answer type }}\]
然后同样使用hinge loss损失函数进行训练:
其中\(a^{\prime}\)为答案
实验证明,这篇论文的方法比《Question Answering with SubgraphEmbeddings》的方法效果更好。作者构建了消融实验,用数据证明了上述的各个特征和步骤都对最终的结果有着正面的影响。
]]>《Question Answering with Subgraph Embeddings》发表于 2014 年的EMNLP,提出了一种基于对问题嵌入与候选子图嵌入进行打分的排序学习的方法,是基于信息检索的知识库问答中比较有代表性的工作。
上图展示了该工作的主要方法。如图所示,此方法主要包含问题嵌入与子图嵌入两个分支。
为了获得问题的嵌入,该文使用了同一种稀疏统计表示方式,即统计问题中每个单词的词频,从而得到与整个词表大小相同的稀疏统计向量表示:
作者对答案的嵌入表示
(1)实体嵌入:与问题嵌入的方式相同,直接通过实体 one-hot在共享的词嵌入参数矩阵中获取对应的嵌入: \[g(a)=\mathbf{W} \psi(a),\] 其中\(\psi(a)\)为与
(2)路径嵌入:该工作会考虑最多两跳的答案路径。例如对一个两跳的答案路径:(barackobama, people.person.place of birth, location. location.containedby,hawaii)中的头实体、尾实体和路径上的所有谓词都使用
(3)子图嵌入:对一个答案,在一跳或两跳的范围内构建子图,对这个子图中包含的实体和关系同样使用
通过上述的方法分别获取问题的嵌入
实验证明,这篇论文提出的方法在 WebQuestions数据集上得到了优秀的结果。对比实验也发现,使用子图嵌入来作为答案的嵌入比其它两种方式效果更好。
]]>WAITING
状态(如图所示),而实际上服务器的 GPU并非处于全部被占用的状态。经过查阅 issue 与查看源码,发现 nni 判定 WAITING
状态的任务在何时可以执行并将状态转变为 RUNNING
的条件是文件/tmp/<user_name>/nni/script/gpu_metrics
中gpuInfos
字段下各 GPU 的状态activeProcessNum
。由于服务器上有 GPU 实时监控软件在不断调用nvidia-smi
程序,导致 nni 的检查 GPU 状态的程序一直卡在nvidia-smi
处。
而 nni 中专门有个脚本可以用来检测 GPU 使用情况并更新gpu_metrics
文件:/<python_path>/site-packages/nni/tools/gpu_tool/gpu_metrics_collector.py
。查看代码可以看到:
1 | def main(argv): |
因此,将环境变量 METRIC_OUTPUT_DIR
设定在gpu_metrics
所在的目录,即可自动生成最新的 GPU状态。在我这儿卡住的服务器上 kill 掉无响应的 nvidia-smi
程序,执行METRIC_OUTPUT_DIR=/tmp/<user_name>/nni/script/ python3 -m nni.tools.gpu_tool.gpu_metrics_collector
,成功地让一直卡在WAITING
状态的程序继续运行,状态转为RUNNING
。
为了后续不被卡住,特意用了 crontab
定期执行一次杀掉nvidia-smi
和执行 gpu_metrics_collector
的操作,一劳永逸。
当我们开始在 React 中重构前端项目时,可复用 UI组件还不在我们设计和开发工作流程之内。我们的 jQuery 前端项目主要是基于Twitter 的 bootstrap组件构建的,这些组件针为特定的用例进行了调整,或通过附加功能进行了扩展。我们通过从旧设计中汲取精华并加以改进,让每个特性都有了新的设计。随着团队和应用的不断发展,我们的各个组件朝着不同的方向改进,导致了文本大小、配色、按钮和链接出现了各种各样的变化,最终使得整个应用的用户体验脱节。
在 React中重构前端项目,给了我们重新思考设计和开发工作流的机会,也让我们有机会将重点放在为用户提供更统一的体验上。这一点非常重要,因为我们所需要做的,就是让应用程序更容易访问和能更快速地响应。这也促使了新的组件库的诞生,并继而驱动了设计系统的需求。这个迭代过程一开始非常困难且缓慢,但随着时间的推移,新的组件库和设计系统变得越来越实用,让开发人员和设计师兴奋不已。
设计系统是关于如何创建、组织存档和使用 UI组件的综合指南。它定义了一组适用于所有设计的规则、约束、原则和最佳实践。设计系统的核心元素是一组UI组件,如按钮、链接和列表。对每个组件,你都可以为其附上说明,描述在设计期间为这个组件所做选择;以及为每个组件撰写文档,用以定义组件的规则、行为和约束,提供用例和其它可以通过文本描述的细节。
组件库是用编程语言实现的可复用 UI组件的集合。当得到设计系统的支持时,它也可以被视为设计及其指导方针的交互式实现。
如 Airbnb 的Karri Saarinen所言,统一的设计系统对于实现更好更快的构建来说,非常重要;更好的原因是用户更容易理解统一的体验,更快的原因是它为我们提供了一种共同的语言来进行协作。
在 Karify看来,设计系统可以帮助我们创建并遵循自己的设计准则,也可以帮助我们在多种平台和设备上创建出一个统一的用户体验。最后,它还可以帮助我们的团队更高效、更敏捷、更紧密地合作。以下是我们发现的一些更详细的优势:
与任何其它方法一样,我们在设计系统的开发中也发现了一些缺点:
不过,不要让这些缺点吓到你。学习如何让这些缺点最小化,也是我们设计过程的一部分。随着时间的推移,优势变得比劣势更明显。
首先,我们建议你了解
接着,你就可以开始定义属于你的一套色彩、排版样式和间距尺寸,这将是你的第一个“原子”。它将允许你开始定义你的第一个“分子”,如按钮、链接和页面等。在第一次迭代中,你很可能会遗漏一些用例,因此需要多轮迭代来逐步完善,直到一切都没问题并能经得起时间的考验。
如果你已经有了一个应用程序,但不能一次全部更改,那么我们建议你基于现有的设计系统去对它进行改进。对于每个部分,分析你所拥有的,选择你喜欢的部分,改进你不喜欢的部分。即使这个工作很繁琐,也应该将未来的组件从遗留组件中分离出来。这样,你就可以避免在新组件中使用遗留组件。慢慢地,这会让你达到你的目标。
我们的团队最初由两名设计师和一名前端开发人员组成,后来又有一名开发人员加入了这个团队。这个团队的规模无论是过去还是现在,都足够完成工作,并且也有充足的时间来处理细节问题。不过,我们认为团队的大小应取决于项目的规模和公司的节奏。
从头开始重构前端项目的机会也让我们可以从过去的错误中吸取教训。因此,我们根据用户的反馈来设计新组件。他们经常提到可访问性和响应性问题,为了解决这些问题,我们认为首先需要重新设计导航系统,然后再重新设计应用程序中的每个页面。
我们先将新的导航作为一个整体设计,在它的设计稳定下来之后,我们开始将其分解为更小的组件,这就产生为原子设计所分割好的的原子、分子和有机体。虽然在理想情况下,我们应该从原子开始设计,但如果没有明确的方向,这将是非常困难的事;而现在,我们已经通过拆分整体设计定义好了几个原子和分子,再将它们组合成新的有机体或页面就比较容易了。
在创建组件时,我们将它们定义为符号
(symbols
),并在Sketch库文件中把它们分割为原子
、分子
和有机体
。Sketch有助于将我们的原子
组件作为符号在其它分子
或有机体
组件中使用。在Sketch中,它们被称为嵌套符号
(nested symbols
)。我们确保按级联顺序使用组件,以保证更新只能是单向进行的。
我们的 Sketch 分子
库:按钮组件中的图标组件是从Sketch 原子
库中复用的。
为了记录我们的选择,我们在 Material UI组件指南的启发下,为每个组件(不管它是原子、分子还是有机体)创建了设计指南。紧接着,为不同的组件设定不同指导方针,以及定义一些适用于所有组件的通用方针,其中有些通用指导方针是非常重要的(比如可访问性和风格定位)。这些指导方针是我们唯一的事实来源,它们确保很多细节是一览无余的。为了给人留下印象,它们是一份包含以下部分的简单文档:
我们在根据每一个功能或者项目而创建的模板
或页面
文件中使用组件。当一个组件或页面准备好了,我们通过Zeplin与开发人员和其他参与者分享设计。这个工具允许他们从设计中提取信息,比如颜色、大小和资源。它还允许对任一组件发起讨论,这可以极大地提高协作效率,因为这些细节通常是需要通过开会来进行讨论的。
在 Zeplin 中协作开发的按钮组件。
现在,开发人员可以使用 Zeplin 中的信息来开始构建相应的 React组件了。理想情况下,每个设计组件都应该只有一个 React组件,以保证设计和代码之间的关系尽可能地紧密一些。为了获得灵感,我们经常会看看其他组件库是怎么做的,比如Material-UI。
为了简化这个过程,我们使用了
Storybook 中相同的按钮:准备审核
在我们的设计系统和组件库中,我们都按照类别将组件进行分组,例如按钮、颜色、表单元素、布局、链接、排版等。
从本质上讲,这些工具帮助我们在工作过程中建立起反馈循环机制:设计师可以通过查看Storybook 轻松地对组件库给出反馈,开发人员可以轻松地在 Zeplin对设计进行评论或者下载资源。
总的来说,我们觉得现在这个过程已经足够好了,但有些事情还可以做一些变通。下面是我们一路上遇到的痛点:
最终,在重新设计完导航系统之后,我们对这些问题进行了学习和改进。我们仍然不时地遇到一些复杂的组件,但我认为尽快地发现并指出错误也是其中的一个过程。
在我们看来,构建一个设计系统和一个组件库是值得的。它带来了我们从一开始就在寻找的一致性设计。这并不意味着我们会把这套方案推荐给所有人。在开始之前,我们建议你先确认这对于你的项目而言是否是正确的解决方案。我们认为,只有当你知道或期望产品需要大量不同的页面,并且这些页面具有复用同一组件的复杂交互时,才需要这样做。如果是一家初创公司或小公司,并希望在未来几年扩大规模时,这一点尤其重要。然而,如果你的产品是一个简单的网站,在几年之内不会有太大的变化,那么这可能是过分的。
]]>翻译:Charlo,
lsvih 校对: 贺雪 Amy, Chorer 原文: Buildinga design system and a component library 掘金地址: https://juejin.cn/post/6924152501805678606
注意,实际上我们将会实现的是
我们开始吧!
大多数应用都会从服务端获取状态。我们先从在本地创建状态开始实现(即使我们是从服务端获取状态,也要先用一些状态来初始化应用)。我们将构建一个简单的笔记本应用,这样可以不用再去做千篇一律的TODO应用,而后文也可以看到,做这个笔记本应用也会驱使我们做一些有趣的东西来控制状态。
1 | const initialState = { |
首先,注意我们的数据只是一个简单的 JS 对象。Redux会帮助我们管理状态的改变,但它并不太关心状态本身。
在我们继续深入之前,首先来看看不使用 Redux要怎样开发我们创建的应用。首先,我们需要把 initialState
对象绑定到 window
上,像这样:
1 | window.state = initialState; |
这就是我们的 store!现在我们不需要什么Redux,直接来构建一个新的笔记组件吧:
1 | const onAddNote = () => { |
你可以在
1 | const initialState = { |
虽然这不是一个很实用的应用,但它能正常工作。看起来我们已经证明了不用Redux 也能做出记事本,所以这篇文章已经完结了~
并没有…
让我们展望一下:我们之后加入了一些新的特性,开发了一个很好的服务端,成立了一个公司来销售它,得到了大量用户,然后又添加了大量的新特性,赚了些钱,扩大公司……(想太多了)
(在这个简单的记事本应用中很难看出来)在我们通向成功的道路上,这个应用可能不断的增大,包含数百个文件中的数百个组件。我们的应用会执行异步操作,所以我们将会有这样的代码:
1 | const onAddNote = () => { |
也会有这样的 bug:
1 | const ARCHIVE_TAG_ID = 0; |
以及一些奇奇怪怪、临时的状态改变,几乎没人知道它们是做什么的:
1 | const SomeEvilComponent = () => { |
在很长一段时间内,很多开发者在大型代码库中共同添加代码,我们将会遇到一系列的问题:
最后一点是最糟糕的问题,也是我们选择 Redux的主要原因。如果你想要降低整个应用的复杂性,最好(我个人的观点)通过限制如何、以及在哪里可以改变应用的状态。Redux不是解决其它问题的灵丹妙药,但是这些限制会让问题出现更少。
所以 Redux是怎样提供这些限制并帮助你管理状态的呢?从一个输入当前状态并返回新状态的简单函数开始说明。对于我们的笔记本应用,如果我们提供一个添加笔记的动作,应当得到一个添加新笔记后的状态:
1 | const CREATE_NOTE = 'CREATE_NOTE'; |
如果你不爽 switch
语句,也可以用其他方式写reducer。我经常使用一个对象,并让 key 指向每种类型的handler,像这样:
1 | const handlers = { |
写法并不重要。reducer 是你自己写的函数,可以用任何方式来实现它。Redux完全不关心你怎么做的。
Redux 关心的是,你的 reducer必须是纯函数。意味着你绝对不能这样写:
1 | const reducer = (state = initialState, action) => { |
实际上,如果你像这样改变状态,Redux将不会正常工作。因为虽然你在改变状态,但是对象的引用不会改变(组件绑定的状态是绑定对象的引用),所以你的应用将不会正确地更新。也会导致不能使用一些Redux开发者工具,因为这些工具跟踪的是先前的状态。如果你在持续性地修改状态,将不能进行状态回退。
原则上,修改状态使得组建自己的reducer(也可能包括应用的其他部分)更困难。纯函数是可预测的,因为他们在同样的输入下会产生同样的输出。如果你养成了修改状态的习惯,一切就都完了。函数调用变得不确定。你必须在头脑中记住整棵函数调用树。
这种可预测性的代价很高,尤其是因为 JavaScript原生不支持不可变对象。在本文的例子中,我们将使用原生JavaScript,需要多写一些冗余的代码。以下是我们写 reducer的正确方式:
1 | const reducer = (state = initialState, action) => { |
我在使用...
)。如果你想使用较传统的JavaScript 语法,可以使用Object.assign
。理念都是一样的:不要改变状态,而是为任何状态、嵌套对象、数组创建浅拷贝。对于任何不变的对象,我们只引用存在的部分。我们再仔细看一下这部分代码:
1 | return { |
我们只改变 notes
属性,而 state
属性将保持不变。...state
的含义是,复用已经存在的属性。类似地,在 notes
中,我们只改变我们正在编辑的部分,...state.notes
中的其他部分将不会改变。这样,我们可以借助 shouldComponentUpdate
或 PureComponent
,使得有未改变的note 作为 props 的组件避免重复渲染。记住,我们还需要避免像这样写reducer:
1 | const reducer = (state = initialState, action) => { |
你又得到了简练的修改对象的代码,而且实际上 Redux可以在这种情况下正常工作,但是将无法进行优化。每个对象和数组在每次状态改变时都会是全新的,所以任何依赖于这些对象和数组的组件都将会重新渲染,哪怕你实际上没有对这些组件的状态做任何修改。
我们不可变的 reducer肯定需要更多的类型定义,也会有更高的学习成本。但以后,你将会为改变状态的函数是独立的,而且容易测试而感到高兴。对于一个真实的应用,你可能想要看一下像
我们来把一个 action 接入我们的 reducer,并生成一个新的 state。
1 | const state0 = reducer(undefined, { |
现在 state0
看起来像这样:
1 | { |
注意,我们把 undefined
作为状态的输入。Redux 总是传入undefined
作为初始状态,而且你一般需要使用state = initialState
这样的方式来选择初始状态对象。下一次,Redux 将会输入先前的状态。
1 | const state1 = reducer(state0, { |
现在 state1
看起来像这样:
1 | { |
你可以在这里使用我们的 reducer
1 | const CREATE_NOTE = 'CREATE_NOTE'; |
当然,Redux并不会像这样创建更多的变量,但我们将会很快讲到真正的实现。重点是,Redux的核心只是你写的一小块代码,一个简单地接收上一个状态,并返回下一个状态的函数。为什么这个函数被叫做reducer?因为它可以被接入标准的 reduce
函数。
1 | const actions = [ |
然后,state
将会看起来和之前的 state1
一样:
1 | { |
你可以在这里向我们的 actions
数组添加元素,并输入给reducer
1 | const CREATE_NOTE = 'CREATE_NOTE'; |
现在,你可以理解为什么 Redux 自称为“一个可预测的 JavaScript应用状态容器”。输入一系列相同的action,你将得到相同的状态。函数式编程必胜!如果你听说过 Redux可以复现之前的状态,这就是大致的原理。实际上,Redux 并不会引用一个action列表,而是会使用一个变量指向状态对象,然后不断改变这个变量指向下一个状态的对象。这是在你的应用中允许的一个重要的改变(mutation),但是我们需要把这种改变控制在store 中。
我们来创建一个 store吧。它可以保存我们单个的状态变量,并提供一些存取状态的方法。
1 | const validateAction = action => { |
现在你可以看到,我们为什么使用常量而不是字符串。我们对于 action的检测比 Redux 更宽松,但也足以保证我们不拼错 action类型。如果我们传入字符串,action 将会直接进入 reducer 的默认分支(switch的default),什么都不会发生,错误可能会被忽视。但如果我们使用常量,拼写错误将会导致返回undefined
并抛出错误,让我们立刻发现错误并修复它。
我们来创建一个 store 并且使用吧:
1 | // Pass in the reducer we made earlier. |
现在已经可以使用了。我们有了一个 store,它可以使用任何我们提供的reducer来管理状态。但是还缺少一个重要的部分:一种订阅状态改变的方法。没有这种方法,我们就需要用一些笨拙的命令式代码。如果将来我们引入了异步actions,它就完全不能用了。所以我们来实现订阅吧:
1 | const createStore = reducer => { |
这是一点点额外的并不太难理解的代码。其中的subscribe
函数接收一个 handler
函数并把它添加到 subscribers
列表中。它还会返回一个用于取消订阅的函数。任何时候我们调用了dispatch
,我们就通知所有这些handler。现在每次状态改变时,重新渲染就很简单了。
1 | /////////////////////////////// |
可以在 dispatch
函数和用户的 action 联系起来。我们将会很快讲到这部分。
如何写出可以和 Redux 配合使用的组件呢?只用简单的接收 props 的 React组件就行了。你实现了你自己的状态,所以写的组件能和这些状态(至少是一部分状态)配合就可以了。有一些特殊情况可能会影响你的组件设计(特别是涉及到性能问题的时候),但是在大多数情况,简单的组件都不会有问题。我们从最简单的组件开始开发我们的应用吧:
1 | const NoteEditor = ({note, onChangeNote, onCloseNote}) => ( |
没什么特别的。我们可以把 props输入给这些组件,并且渲染它们。但是需要注意传入的 openNoteId
属性以及 onOpenNote
和 onCloseNote
的回调:我们需要决定状态和回调存放在哪里。我们可以直接使用组件的state,这当然没问题。但当你开始使用Redux,也没有规定说所有的状态都必须放到 Redux store中。如果你想知道什么时候使用 store 存放状态,只要问自己:
组件卸载后,这个状态还需要存在吗?
如果不需要,很可能采用组件自身的 state存储状态更合适。对于需要保存在服务器,或者跨组件(各组件独立加载和卸载)共享的状态而言,Redux很可能是更好的选择。
有时候 Redux 很适用于易变的状态。特别是状态需要随着 store中状态的改变而改变时,把它存放在 store中可能更容易一些。对于我们的应用而言,当我们创建一个笔记时,我们需要把openNoteId
设置为新的笔记id。在组件中做这件事很笨拙,因为我们需要在componentWillReceiveProps
中监控 store状态的变化。我并不是说这是错的,只是这样很笨拙。所以对于我们的应用,我们将把openNoteId
保存在 store状态中(在真实的应用中,我们可能还需要用到路由。后文也简单介绍了使用路由的情况)。
另一个需要把易变状态放在 store 中的原因是可能是为了更容易从 Redux开发者工具中访问它。通过 Redux 开发工具可以更容易的查看 store中存储的数据,同时还可以使用状态回退之类的有趣的功能。从组件内部状态开始,再切换到store状态是很容易的。只要提供一个容器组件来将本地状态进行包装即可,就像用store 来包装全局状态一样。
那么,我们来修改我们的 reducer 来处理易变状态吧:
1 | const OPEN_NOTE = 'OPEN_NOTE'; |
好了,现在我们可以把整个东西组装起来。我们不会修改现有的组件。我们将会创建新的容器组件,从store 获取状态并传递给 NoteApp
:
1 | class NoteAppContainer extends React.Component { |
哈哈,可以了!
现在应用通过派发 action 来使得 reducer 更新 store存储的状态数据,同时使用订阅确保了视图渲染的数据和 store状态数据保持同步。如果遇到状态数据异常,我们不再需要检查组件本身,只需要检查触发的actions 和 reducer 即可。
好了,所有东西都能用了。但是…还有些问题。
store
对象。否则,我们就需要将 store
传遍整个组件树。或者我们要在顶部节点绑定一次,然后把所有东西通过树传递下去。这在大型应用中可不太好。所以我们需要 React Redux 中提供的 Provider
和connect
。首先,来创建一个 Provider
组件:
1 | class Provider extends React.Component { |
代码很简单,Provider
组件使用 React 的 store
转变成 context 属性。Context是一种从顶层组件向底层组件传递信息的方式,它不需要中间的组件显式传递信息。总的来说,你应该避免使用context,因为
如果你想要你的应用稳定,不要使用 context。这是个试验API,并可能在未来的 React 版本中被放弃。
这就是我们自己使用代码实现而不直接使用 context 的原因。我们把这个试验API封装起来,这样如果它变了,我们可以改变自己的实现,而不需要开发者修改代码。
所以我们需要一种方式把 context 转化成 props。这就是connect
的作用。
1 | const connect = ( |
这有一点点复杂。说实话,和真正的实现相比,我们偷懒了很多(我们将在本文结尾的性能一节中讨论),但已经和真正的Redux 的大概原理很接近了。connect
是一个connect
吧,它会变得更实用的。
1 | const mapStateToProps = state => ({ |
嘿,看上去好多了!
传给 connect
的首个函数(mapStateToProps
)从我们的 store
中获取当前的 state
并返回一些 props。第二个传给connect
的函数(mapDispatchToProps
)会获取我们store
的 dispatch
方法,并返回一些props。connect
给我们返回了一个新的函数,把我们的组件NoteApp
传给这个函数,会得到一个新的组件,它将会自动获取所有这些props(和我们额外传入的部分)。
现在我们需要使用我们的 Provider
组件,以使得connect
不必把 store 放在 context
中。
1 | ReactDOM.render( |
很好!我们的 store
被在顶部传入一次,然后使用connect
接收 store
来完成所有的工作(声明式编程万岁!)。Provider
和 connect
整理好的应用
现在我们已经写了一些很实用的东西,但还缺了一块:在某些环节中,我们需要和服务器通信。现在我们的action 是同步的,该如何发出异步 的 action呢?我们可以在组件中获取数据,但是这有一些问题:
Provider
和connect
)并不是专用于 React 的。最好有一个 Redux解决方案。connect
的东西来获取数据。Redux 是同步的,我们应该怎么做呢?把一些东西放在 dispatch 和改变store 状态的操作之间。这就是中间件。
首先,我们需要一种把中间件传给 store 的方式:
1 | const createStore = (reducer, middleware) => { |
变得复杂了一些。重要的是最后的 if
语句:
1 | if (middleware) { |
我们了创建一个”重新派发 action“的函数:
1 | const dispatch = action => store.dispatch(action); |
如果中间件决定要发出一个新的 action,这个 action将会通过中间件传递下去。我们需要创建这个函数,因为我们需要修改 store 的dispatch
方法。(这也另一个用可变对象简化问题的例子,我们开发 Redux时可以破坏规则,只要它能够帮助开发者遵守规则。^_^
)
1 | store.dispatch = middleware({ |
上面的代码调用了中间件,传给它一个能进行“re-dispatch”的函数和getState
的函数。这个中间件需要返回一个新的函数,拥有用来接收调用下一个 dispatch函数的能力(原始的 dispatch函数)。如果你读到这里觉得头晕了,不要担心。创建和使用中间件实际上是很容易的。
Okay,我们来创建一个延迟一秒再 dispatch的中间件。它没有实际用处,但能够说明异步的原理:
1 | const delayMiddleware = () => next => action => { |
这个函数的签名就看起来很傻,但是能够嵌入我们之前创建的拼图中。它是一个函数,返回一个接受下一个dispatch 函数的函数,这个函数接受 action。看起来似乎 Redux在疯狂使用箭头函数,但这是有原因的,我们将很快说明。
现在,我们开始在 store 中使用这个中间件吧。
1 | const store = createStore(reducer, delayMiddleware); |
哈,我们把我们的 app变慢了!这可不妙。但是我们有异步操作了!请试一试
调整 setTimeout
时间可以把它变得更糟糕,或更好些。
来写一个更有用的中间件,用于记录日志吧:
1 | const loggingMiddleware = ({getState}) => next => action => { |
这就很有用了。我们把它加入我们的 store 中。但是我们的 store只能接收一个中间件函数,因此需要一种方式来组装我们的中间件。所以,我们需要一种方法,来把很多中间件函数变成一个中间件函数。来写applyMiddleware
吧:
1 | const applyMiddleware = (...middlewares) => store => { |
这不是个优雅的函数,但你应该可以跟得上。首先需要注意的是它接收一个中间件的列表,并返回一个中间件函数。这个新的中间件函数和之前的中间件有同样的签名。它接收一个store(只包含新的派发 action 的 dispatch
和getState
方法,不是整个store)并返回另一个函数。对于这个函数:
好了,现在我们可以按预期使用所有的中间件了:
1 | const store = createStore(reducer, applyMiddleware( |
现在我们的 Redux 实现可以做所有的事了!
在浏览器中打开控制台,可以看到日志中间件发挥作用了。
我们来做些真的异步操作吧。在此介绍一种“thunk”中间件:
1 | const thunkMiddleware = ({dispatch, getState}) => next => action => { |
“Thunk”真的只是“函数”的另一个名称,但是它通常意味着“封装了一些未来处理的工作的函数”。如果我们加入thunkMiddleware
:
1 | const store = createStore(reducer, applyMiddleware( |
现在我们可以这样做:
1 | store.dispatch(({getState, dispatch}) => { |
Thunk 中间件是一柄大锤,我们可以把任何东西从 state中拉出来,并在任何时候把任何 action 派发出去。这十分方便灵活,但随着你的app变得越来越大,它可能变得危险。但在这里还挺好用的。我们用它来做一些异步操作吧。
首先,创建一个假的 API:
1 | const createFakeApi = () => { |
这个 API 只支持一个创建笔记的方法,并返回这个笔记的id。因为我们从服务端获取 id,我们需要进一步改动我们的 reducer:
1 | const initialState = { |
这里,我们在使用 CREATE_NOTE
action来设置加载状态,以及在 store 中创建笔记。我们只用id
属性的存在与否来标记这种区别。你可能需要使用不同的 action,但 Redux并不关心你使用什么。如果你想要一些规范,可以看看
现在,让我们修改 mapDispatchToProps
来发出 thunk吧:
1 | const mapDispatchToProps = dispatch => ({ |
我们的应用在执行异步操作了!
但等等...除了给我们的组件添加一些丑陋的代码以外,我们还发明了中间件来把这些代码清理出去。但现在又放回去了。如果我们创建了一些定制的api 中间件而不是使用 thunk,我们就可以避免这种情况。哪怕是使用 thunk中间件,我们也可以把代码变得更像声明式。
我们可以把在组件中发出 thunk 的操作抽象出来,放进一个函数:
1 | const createNote = () => { |
上面的代码发明了一个 action创建器。这不是什么奇特的东西,只是一个返回 action 的函数。它可以:
我们可以早些创建 action创建器,但是并没有什么理由这样做。我们的应用很简单,所以不需要重复同样的action。我们的 action 很简单,已经足够简洁和声明式了。
来使用 action 创建器修改一下我们的mapDispatchToProps
吧:
1 | const mapDispatchToProps = dispatch => ({ |
这样就好多了!
你自己写了一个 Redux!看起来这篇文章写了很多代码,但是主要是我们的reducer 和组件。我们实际的 Redux 实现还不到
真实的 Redux和真实的应用比这复杂一些。后文中我们将讨论其中一些没讲到的情况,如果你觉得自己掉进Redux 的坑里了,但愿这能给你带来一些希望。
我们的实现所缺少的是,监听特定属性是否真的改变了的能力。对于我们的示例应用而言,这并不要紧,因为每个状态变化都造成了属性的改变。但对于有很多mapStateToProps
函数的大型应用而言,我们只想要在组件真的接收新属性时更新。要扩展我们的connect
函数来实现这一点是很容易的。我们只需要在调用setState
时比较前后的数据即可。我们需要更聪明地使用mapDispatchToProps
。注意,我们每次都在创建新的函数。真正的React Redux库会检查函数的参数,看看它是否依赖属性
。这样,如果属性没有真的改变,就不需要再做一次映射。
你也需要注意,当我们在属性或者 store状态改变时,会调用我们的函数。这些改变可能会瞬间同时发生,从而浪费一些性能。ReactRedux 优化了这一点,也优化了很多其他东西。
除此之外,对于更大的应用,我们需要考虑选择器的性能。比如,如果我们过滤一系列的笔记,我们可不想不停重复计算这个列表。为此,我们需要使用例如
如果你使用原始 JS 数据结构(而不是像 Immutable.js这样的东西),那么我遗漏的一个很重要的细节是在开发时冻结 reducer状态。因为这是 JavaScript,没有什么阻止你在从 store中获取状态之后改变它。你可以在 render
方法或者别的任何东西中改变它。这会造成非常糟糕的结果,并且毁坏一些正在通过Redux 加入的可预见性。我是这样做的:
1 | import deepFreeze from 'deep-freeze'; |
这创建了一个冻结了结果的 reducer。这样,如果你想要改变组件中的 store状态,它将会在开发环境报错。过一段时间,你将能够避免这些错误。但如果你新接触不可变数据,这可能是最容易的练习方式了,对于你和你的团队来说都是如此。
除了性能以外,我们还在我们的 connect
实现上偷了懒,忽略了服务端渲染。componentWillMount
可以在服务端被调用,但是我们不想在服务端设置监听。Redux 使用componentDidMount
和一些其他技巧来使它在浏览器中正常工作。
我们并没有写几个高阶函数,这儿就有一个遗漏的:“store增强器”是一个高阶函数,接收一个 store 创建器并返回一个“增强版”的 store创建器。这不是一个常见的操作,但它可以被用来创造 Redux开发者工具之类的东西。真正的 applyMidleware
实现 就是一个 store 增强器。
这个 Redux的实现都没有进行任何测试。因此无论如何,请不要在任何实际的产品中使用这个实现!这只是在本文中用于说明Redux 原理的!
这款笔记应用目前是将数据保存在以数字作为 key 的对象中。这意味着每一个JS 引擎都会按照创建的顺序来给它们排序。如果我们的服务器返回 GUID或者其它未排序的主键,我们将很难排序。我们不想把笔记存放在数组中,因为要通过id获取特定笔记就不容易了。所以对于真实应用而言,我们可能需要用数组存放排好序的id。另外,如果用 reselect
来缓存 find
操作结果的话,也可以尝试使用数组。
有时候,你可能会想要创建一些这样的中间件:
1 | store.dispatch(fetch('/something')); |
别这样做,一个返回了 promise的函数实际上已经开始运行了(除非它是个不正常的延迟promise)。这也意味着我们无法用任何中间件来处理这个action。比如,我们就不能使用节流中间件。另外我们也不能正常使用回放,因为这需要关闭dispatch 函数。但是任何调用这个 dispatch
的代码都已经完成了工作,所以不能把它停掉。
确保你的 action 是对副作用的一种描述,而不是副作用本身。Thunk是不透明的,不是最好的描述,但是它们也是对副作用的描述而不是副作用本身。
路由可能会很奇怪,因为浏览器持有当前位置的一些状态,还有一些用于改变位置的方法。你一旦开始使用Redux,就可能想要把路由状态放在 Redux store中。我就是这样做的,所以我创建了一个
基于Redux,有大量中间件和工具组成的生态系统。下面罗列了一部分项目,你可以都看看,但推荐还是先熟悉基础知识!
你肯定会想看看
如果你想要发出多个同步 action,但只触发一次重新渲染,
如果异步 action 或副作用在使用
如果你想用 GraphQL,可以看看
尽情享受吧!
]]>翻译:tanglie1993,
lsvih 校对: nia3y, JohnieXu 原文: Build Yourselfa Redux 掘金地址: https://juejin.cn/post/6923922875191656462
解压 docker 文件: 1
tar xzvf /path/to/<FILE>.tar.gz
复制 docker 文件至 /usr/bin/ 目录下 1
sudo cp docker/* /usr/bin/
使用 docker service 让 dockerd 自启动 1
sudo cp docker_service/* /etc/systemd/system
重启 systemctl,启用 docker 自启动 1
2
3sudo systemctl daemon-reload
sudo systemctl enable docker
sudo systemctl start docker
创建 docker 用户组,并将当前用户加入其中 1
2
3sudo groupadd docker
sudo gpasswd -a $USER docker
newgrp docker
重启 reboot
。
验证 docker 安装情况: docker images
1 | edge{ |
可以让连边的 label 正常显示:
但是,文字覆盖在连边上效果很不好,因此有时候会使用outline
等方式,让文本更加突出。而根据需求,现在需要让label 文本和连边错开(即文本在边的上边而不是重合),使用官方提供的margin 等接口,都会在连边不是水平的时候导致文本的错位:
上面两个图片是
1 | edge{ |
的效果。
1 | graph.cy.style().selector('edge').style({'label': label => label.data().name + "\n\n\u200b"}).update() |
直接让一行标签变成三行标签,这样就刚好和连边错开了。其中\u200b
是空白的字符,在图中不会显示,拿来做占位符恰好合适。效果如下:
两个世纪后,另一位德国数学家戴维·希尔伯特(DavidHilbert)乐观地宣布,判定性问题的答案必须是,是的,我们能并且会知道任何数学问题的答案。他于1930 年在德国柯尼斯堡(Königsberg)的一次讲话中曾说:
Wir müssen wissen — wir werden wissen.(“我们必须知道 ——我们会知道。”)
但是我们会知道吗?
历史表明希尔伯特的乐观主义是短暂的。同年,奥地利数学家库尔特·哥德尔(KurtGödel)通过证明他著名的不完备定理(incompletenesstheorem)表明我们的数学知识是有极限的。
下面是理解哥德尔定理的简单方法。请考虑以下陈述。
命题 S:此命题不可被证明。
现在,假设在数学中我们可以证明 S 为真。但是这样一来,命题 S本身将为假,从而不一致。好吧,那么让我们假设相反的情况,即我们无法在数学中证明S。但这将意味着 S本身为真,并且数学中包含至少一个无法证明为真的真命题。因此,数学要么不一致,要么不完备。如果我们假设它是一致的(命题不能同时为真和假),这只能得出数学是不完备的结论,即存在不能完全证明是真命题的真命题。
哥德尔(Gödel)对不完备定理的数学证明比我在此概述的要复杂得多,这从根本上改变了希尔伯特(Hilbert)所宣称的完整知识是可行的观点(“我们会知道”)。换句话说,如果我们假设数学是一致的,那么我们必然会发现无法证明的真命题。
例如,哥德巴赫猜想(The Goldbachconjecture),根据该猜想,每个偶数都是两个素数的和:
6 = 3 + 3 8 = 3 + 5 10 = 3 + 7 12 = 7 + 5,依此类推。
至今还没有人发现反例,如果猜想是真的,那也就不存在反例。得益于哥德尔的贡献,我们知道有无法证明的真命题,但不幸的是,我们没有办法找出这些命题。哥德巴赫猜想可能就是其中之一,如果是这样,那么尝试证明它就是浪费时间。
艾伦·图灵(AlanTuring)第一次了解哥德尔不完备定理时还是剑桥大学的研究生。在那段时间里,图灵忙于做一种机器的数理设计。这种机器可以处理任何输入并计算结果,与莱布尼茨几个世纪前所设想的相似。今天这些概念化的机器被称为图灵机,是现代数字计算机的蓝图。简单来说,图灵机可以看作现代计算机程序。
图灵当时在研究所谓的停机问题,可以描述如下:
是否有一个程序可以确定另一个程序会停止(停机)还是不停(死循环)?
图灵证明了停机问题的答案是“否”,即不存在这样的程序。与哥德尔的工作类似,他也是用“反证法(proofby contradiction)”证明的。假设存在一个程序halts(),它能确定给定程序是否将停止。但是,我们还可以构建以下程序:
1 | def g(): |
看看这里发生了什么?如果 g 成立,则 g 不成立;如果 g 不成立,则 g成立。无论哪种方式,我们都将得到一个矛盾。因此,程序 halts()不存在。
哥德尔证明了数学是不完备的,而图灵证明了在某种意义上计算机科学也是“不完备的”。某些程序根本不存在。这不仅是理论上的好奇:停机问题在当今的计算机科学中具有许多实际意义。例如,如果我们希望编译器为给定的程序找到最快的机器码,那么我们实际上是在尝试解决停机问题—— 而我们已经知道该问题是无法解决的。
哥德尔和图灵通过揭示存在着的一些根本无法解决的问题,证明了我们所能知道的在理论上存在极限。但是此外,还有其他问题是理论上可以解决,但是因为求解的时间太长了,而我们实际上无法解决的。这里我们将会说明P 问题和 NP 问题的区别。
P问题是可以在“合理的时间”内解决的问题。在这里,“合理的时间”的含义是“多项式(polynomial)时间”(因此称为P)。求解这些问题的计算复杂性随问题输入规模的增长而倍数增加(想想乘法或排序问题)。
另一方面,NP 问题是无法在合理时间内解决的问题。NP是非确定性多项式(non-deterministicpolynomial)的英文缩写,它的含义是可以用多项式级的计算复杂度验证问题的一个解,但不能用多项式级的计算复杂度求解。求NP问题的解的复杂度是指数级的,而不是多项式的,这会产生巨大的实际差异。NP问题的例子包括最佳调度,预测蛋白质的折叠方式,加密消息,解决数独难题,最佳包装(又称背包问题)或最佳路由(又称旅行商问题)。一些问题(例如找到函数的离散傅立叶变换)最开始属于NP 问题,但由于开发了新的、巧妙的算法来简化求解,最终变成了 P 问题。
当今计算机科学领域中最大的未解之谜之一就是 P 与 NP 问题:P 是否等于NP?换句话说,对于所有我们可以在合理时间内验证一个解的问题,我们是否能在合理的时间内求解?
P 与 NP 问题非常重要,因此被列入“
如今,大多数科学家相信 P 不等于 NP,但是我们能确定吗?P 与 NP问题本身可能类似于希尔伯特的 Entscheidungs问题或图灵的停机问题:这个问题可能根本没有答案。
如果你喜欢本文,也可以查看以下内容:
]]>本文发布于掘金:https://juejin.im/post/6874475968325484552
通过本文,我们将熟悉树莓派 GPIO及其技术规范。并且,我们将通过了一个简单例子,说明如何使用树莓派的 I/O控制 LED 和开关。
你可能见过 “IoT” 这个术语,它是 Internet ofThings(物联网)的缩写。意思是,人们可以通过互联网控制一台设备(即“物”thing)。比如,用手机控制你房间内的智能电灯泡就是一种物联网的应用。
由于物联网设备可通过互联网控制,所以 IoT设备需要始终与互联网相连。我们主要有两种方式将设备连接至互联网:以太网网线和WiFi。
物联网设备可被用于各种目的。例如,你可以使用物联网来控制你家的室内温度、照明或者在回家前打开某些设备,所有这些操作都只需要通过你的手机便能实现。
那么,物联网设备的技术规范有哪些?简言之,它应该包含连接到互联网的工具,有一些输入和输出接口来读写设备的模拟或数字信号,并且使用最少的硬件来读取和执行程序指令。
一个物联网设备配有一个硬件组件,为外部设备读取数字数据和取电提供接口。该接口就是GPIO 或称作 General Purpose InputOutput(通用输入输出接口)。这种硬件组件基本上都是由一系列可以连接到外部设备的引脚(或管脚,pin)构成。
这些 GPIO引脚可以被程序控制。比如,在满足一些条件的情况下,我们可以给一个 GPIO引脚施以 5V的电压,任何连接到该引脚的设备都会被开启。程序也能够监听来自互联网的信号,并根据该信号对GPIO 引脚进行控制。这就是物联网。
从头开始构建这样一个物联网设备可能很困难,因为需要处理的组件有很多。幸运的是,我们可以购买售价低廉的现成的设备。这些设备配有GPIO 硬件和连接互联网的工具。
目前,如果我们想要实现简单的自动化,那么
然而,该控制器不配有内置 WiFi或以太网插孔,并且必须连接外部外围设备(即屏蔽)才能将Arduino 连接到互联网。
Arduino旨在充当外部设备的控制器,而不是成熟的物联网设备。因此,该控制器价格非常便宜,某些最新款的售价可以低至18 美元。
相较于 Arduino,
树莓派(最新版4B)配有以太网连接器、WiFi、蓝牙、HDMI 输出、USB 连接器、 40 个GPIO 引脚和其他基本功能。它由 ARM CPU、博通 GPU 和 1/2/4 GB 的 RAM驱动。你可以在
尽管树莓派的硬件很丰富,但它最新版的售价也仅在 $40 到 $80间。别忘了,这可是一台拥有原生操作系统的成熟计算机。这意味着我们不需要连接外部计算机就能对其进行编程。
与我们日常使用的电脑不同,树莓派提供了一个 GPIO硬件组件来控制外部设备。这使得树莓派成为了一种几乎可以做任何事情的设备。
让我们了解一下新版树莓派 GPIO 的技术规格。
树莓派(4B 版)总共 40 个 GPIO引脚,分布在 20 x 2
的阵列当中。如下图所示,每个引脚都有特定的用途。
在讨论每个引脚的功能之前,让我们先了解一些协议。每个引脚都有特定的编号,我们就是通过这些编号从软件中控制这些引脚。
在圆圈中,你可以看到的数字是 GPIO硬件上的物理引脚编号。例如:1 号引脚 提供 3.3V的恒定电压。该编号系统称为 Board pin或物理引脚编号系统。
由于树莓派 4B 使用
我们既可以选择遵循 Board pin 编码,也可以用BCM 编码系统。然而,由于我们用 GPIO编程库的原因,同时使用该两种编码系统可能会遇到问题。大多数库都偏好于 BCM编号系统,因为它引用于博通 CPU 芯片。
从现在开始,如果文中出现 x号引脚,就意味着这是引脚板上的物理引脚编号。如果提到了BCM,则意味着我们在使用 BCM 引脚编号。
1 号和 17 号引脚提供3.3V 电源,而 2 号和 4号引脚提供 5V电源。当你打开树莓派时,这些引脚便会提供恒定功率,并且无论在何种条件下,这几个引脚都是不可编程的。
6 号、 9 号、 14号、 20 号、 25 号、30 号、 34 号和 39号引脚支持接地。它们应该与电路的阴极相连。电路中所有的接地连接都可以用同一个接地引脚,因为它们都连接到同一根地线。
如果你想知道为什么有这么多接地引脚,可以查看
这个帖子。
除了电源和接地引脚外,其他引脚均为通用输入和输出引脚。当GPIO 引脚用于输出模式时,它在开启时提供 3.3V恒定功率。
在输入模式下,GPIO引脚也可用于监听外部电源。从技术上看,当用 3.3V电压供给处于输入模式的 GPIO引脚时,该引脚将被读取为逻辑高电平或1。当引脚接地或提供 0V功率时,它会被读作逻辑低电平或 0。
而输出模式更加简单。在输出模式下,我们接通一个引脚,设备会通过该引脚提供3.3V的电压。而在引脚的输入端,我们需要监听引脚上的电压变化,当引脚处于逻辑高电平或低电平时,我们可以执行其他操作,如打开一个输出GPIO 引脚。
SPI(
I²C(Inter-Integrated Circuit(内置集成电路))类似于 SPI,但它支持多个主设备。此外,与SPI 不同,它只需要两条数据线来容纳多个从机。不过这会让 I²C 比 SPI慢。
UART(
树莓派提供了一个底层接口用于通过 GPIO引脚就像我们前文讨论过的输入输出模式一样启用这些接口。然而,并非所有的GPIO 引脚都可以实现这些通信方式。
在下图中,你可以看到哪些 GPIO 针脚是可以通过 SPI、I²C 和 UART协议进行配置的。你可以访问
除了简单的输入或输出模式,GPIO 引脚有 6种模式,但每次只能在一种模式下工作。当你在上面那个网页中点击GPIO 引脚时,你可以在屏幕右侧看到它的工作模式。右表中的 ALT0 至 ALT5描述了这些模式。
你还可以通过
这个视频来了解这些通信协议的规范。在本教程中,我们不会涉及这些通信协议,但是,我将在接下来的文章中讨论相关主题。
我们已经讨论过电源和 GPIO引脚的电压规格。因为树莓派官方文件中未曾提及具体规范,所以现行规范还不太明确。
不过可以确定的是,我们在处理电流时,必须要遵循安全措施:从任何引脚获取的最大电流应小于或等于16mA。因此,我们必须调整负载以满足这一要求。
如果我们已经将多个设备连接到树莓派 GPIO 和其他端口(如USB),那么我们必须确保从电路获取的最大电流小于50mA。
为了限制电流,我们可以在电路中增加电阻,使得最大电流不会超过这些限制。当一个设备需要的电流比树莓派的最大限制还要大时,应当使用继电器开关。
输入模式使用的也是相同的规范。当 GPIO引脚被用作漏极(而非 源电流)时,我们不应该供应超过 16mA的电流。此外,当多个 GPIO 引脚用作输入时,总共不应施加超过50mA 的电流。
我相信你已经走过一遍树莓派的设置流程。这意味着你已经安装了一个
我们需要做的第一件事就是创建项目目录。我已经在/home/pi/Programs/io-examples
这个路径下创建了项目目录,我们所有的程序都将作为教程示例保存在该路径下。
由于我们想通过 Node.js 来控制 GPIO 引脚,首先我们必须安装Node。你可以选择你最喜欢的方法,但我个人会使用
一旦装好了 NVM,我们就可以安装特定版本的 Node。我将使用 Nodev12,因为它是最新的稳定版本。要安装 Node v12,请输入以下命令行:
1 | nvm install 12 |
一旦树莓派安装了 Node.js,我们就可以继续创建项目了。因为我们想要控制GPIO 引脚,所以我们需要一个库来为我们提供一个简单的应用编程接口。
onoff
包。
1 | cd /home/pi/Programs/io-examples |
现在一切准备就绪,我们可以开始电路设计并编写第一个程序来测试 GPIO的能力。
在本例中,我们将以编程方式打开红色LED。让我们先看看下面的电路图:
从上图可以看出,我们已经将 6号引脚(接地引脚)连接到了线路板的负极(地线)上,并将BCM 4 连接到了 1k ohm电阻的一端。电阻器的另一端连接到红色 LED 的输入端上,LED的输出端接地。
除了有个电阻,这个电路没什么特别的。需要这个额外的电阻是因为红色 LED在 2.4V 电压下工作,而提供 3.3V 电压的GPIO 会损坏 LED。此外,LED 采用的 20mA超过了树莓派的安全阈值,因此,也需要这个电阻来防止电流过大。
我们可以选择 330 ohms 到 1k ohms的电阻。这个数值范围的电阻会影响电流大小,但都不会损坏 LED。
从上述电路来看,电路中唯一的变量是 BCM 4引脚输出。如果引脚打开(3.3V),电路将闭合,LED将发光。如果引脚关闭(0V),电路断开,LED不会发光。
让我们编写一个程序,实现以编程方式打开 BCM 4 引脚。
1 | const { Gpio } = require( 'onoff' ); |
在上述程序中, 我们导入 onoff
包并引入 Gpio
构造函数。用设定好的配置创建 Gpio
类来配置一个GPIO。上面的例子中,我们将 BCM 4设置成了输出模式。
你可以参考该
onoff
模块的API文档来了解各种配置选项和 API。
Gpio
类创建的实例提供了与该引脚交互的高阶API。writeSync
方法会将 1 或0 写入引脚,以实现开启或关闭引脚。当引脚设为1 时,引脚开启并输入3.3V 电源。当它设为 0时,引脚会关闭且不再提供任何电源电压(0V)。
使用 setInterval
时,我们就是在运行一个无限循环,不断地调用ledOut.writeSync(val)
方法在 ledOut
引脚中写入0 或 1。让我们使用 Node.js 来运行这个程序:
1 | node rpi-led-out.js |
由于这是一个无限循环的程序,一旦启动,它就不会终止,除非我们使用ctrl + c
强制终止程序。在该程序的生命周期内,它将每隔3 秒切换一次 BCM 4 引脚的状态。
树莓派 GPIO 有意思的一点是,一旦 GPIO 引脚被设为 1或 0,它将一直保持不变,除非我们覆盖该值或关闭树莓派的电源。比如,当你启动程序时,LED处于熄灭状态,但当你终止程序时,LED 可能会保持亮起状态。
众所周知,当把 GPIO 用作输入时,我们需要提供接近3.3V的电压。我们可以连接一个开关(按钮)直接从3.3V 引脚提供电压,如下图所示:
在输入开关之前,我们已经在电路中使用了一个 1K ohm的电阻。它能防止 3.3V电源产生过大的电流,避免开关熔断。
我们还连接了一个 10K ohm电阻,该电阻也从按钮的输出端汲取电流并接地。这类电阻被称为下拉电阻(因为它们在电路中的位置),它们会将电流(或大气中电荷聚集产生的电流)导向地面。
我们也可以增加一个上拉电阻,从 3.3V引脚导出电流,供给给 GPIO 的输入引脚。在这种配置下,输入引脚会始终读取高 或1。按下按钮时,开关在电阻和地面之间产生短路,将所有电流导向地面,并且没有电流通过开关到达输入引脚,读数为0。
此处有一段很棒的视频演示了上拉和下拉电阻。
开关的输出连接到 BCM 17引脚。当按下按钮(开关)时,电流将通过开关流入 BCM 17引脚。然而,由于 10K ohm电阻给电流提供了更大的障碍,大多数电流会流向由红色虚线表示的回路。
未按下按钮时,由红色虚线表示的回路闭合,没有电流流过。然而,由灰色虚线表示的环路是闭合的,BCM17 引脚接地(0V)。
增加一个 10k ohm 电阻是为了让 BCM 17引脚接地,这样它就不会将任何大气干扰读取为高输入。如果不将输入引脚接地,输入引脚会保持在浮动状态。在这种状态下,由于大气干扰,输入引脚可能读取为0 或 1。
既然电路已经准备好了,让我们编写一个程序来读取输入值:
1 | const { Gpio } = require( 'onoff' ); |
在上面的程序中,我们将 BCM 17引脚设置为输入模式。Gpio
构造函数的第三个参数配置了我们何时需要引脚输入电压变化的通知。该参数名为edge
,因为我们读取的是电压上升和下降周期的边缘电压值。
edge
参数可以有以下值:
当使用 rising
值时,如果 GPIO 引脚的输入电压从0V 上升(至3.3V),我们将收到通知。位于此位置时,引脚将读取逻辑高位或1,因为该引脚获得了更高的电压。
当使用 falling
值时,如果输入电压(从3.3V) 降至0V,我们将收到通知。位于此位置时,引脚将读取逻辑低位或0,因为它正在失去电压。
当使用 both
值时,我们将收到上述两个事件的通知。当电压从0V 上升(至输入高电平或 1)或从 3.3V下降(至输入低电平或0)时,我们都会收到到这些事件的通知。
此处不讨论
none
值,请阅读文档了解更多信息。
输入模式下 GPIO 引脚上的 watch
方法监视上述事件。这是一个异步方法,因此我们需要传递一个回调函数,该函数接收输入高(1)或输入低(0)值。
由于我们使用的是 both
值,所以 watch
方法将在输入电压上升时以及输入电压下降时都执行回调。按下按钮,你应该会在控制台中看到下面的值:
1 | Pin value 1 (按下按钮) |
如果仔细检查以上输出就能发现,我们有时会在按下或释放按钮时得到重复的值。由于开关机制的两个连接器之间的物理连接并不总那么顺畅,所以,不小心按下开关时,它可以多次连接和断开。
为了避免这种情况,我们可以在开关电路中增加电容,在实际电流流入 GPIO引脚之前充电,并在按钮释放时平稳放电。这种方法非常简单,你可以试一试。
现在我们已经充分理解了 GPIO引脚的工作原理以及配置方法,让我们结合最后两个例子进行讲解。更重要的是,按下按钮时,打开LED 而释放按钮时关闭 LED。让我们先看看电路图:
从以上例子可以看出,我们没有从上面的两个例子中改变任何东西。另外,LED和开关电路都是独立的。这意味着我们之前的程序在这条线路上应该可以正常工作。
1 | const { Gpio } = require( 'onoff' ); |
在上述程序中,我们将 GPIO引脚分别配置为输入和输出模式。由于输入引脚上的 watch
方法提供的值是 0 或1,因此,我们直接把这些值写入输出引脚。
因为我们在 both
模式下用 watch
方法监视输入引脚,当按下按钮发送 1 或者释放按钮发送0 时,watch
方法的回调将被触发。
我们可以直接使用该值写入 ledOut
引脚。因此,按下按钮时,value
为 1
并执行ledOut.writeSync(1)
,会打开 LED。松开按钮时则反之。
以上是我们刚才创建的完整输入/输出电路的演示。为了你本人和树莓派的安全,建议买一个好的外壳和40 针 GPIO 扩展带状电缆。
希望你今天能学到一点东西。在接下来的教程中,我们将构建一些复杂的电路并学习连接一些有意思的设备,如字符LCD 显示屏和数字输入板。
]]>发布于掘金 https://juejin.im/post/6868946182325043207
欢迎你踏上了一条在前端世界中饱含争议的道路!相信大部分读者会在关于如何
文章伊始,先声明一句:无论是在基于 Vue、Angular 还是 React构建的应用,针对如何处理CSS,世界上并没有任何放之四海而皆准的方法。各个项目皆有不同,每种方式也有可取之处!可能这么说显得含糊其辞,但就我所知,在我们的开发社区内,那些追寻新知识,推动网页开发向前发展的人举目皆是。
让我们放下对本文话题的感性认知,先领会下 CSS 世界架构的奇妙之处。
单单谷歌一下“如何在框架内加入CSS”,各种言辞凿凿的关于如何在项目中应用样式的观点和看法便映入眼帘。排除一些无关紧要的信息,我们可以先宏观上挑选出更通用的方法和目的检验一番。
先从我们最熟悉的方式开始:老掉牙的样式表。我们自然可以在应用中<link>
一个外部样式表,活儿就完了。
1 | <link rel="stylesheet" href="styles.css" /> |
我们可以一如往常地写熟悉的CSS。这样做在一般情况下倒没什么问题,然而,当应用逐渐臃肿、越来越复杂时,维护一个样式表就变成了难题。上千行的CSS对应整个应用的样式,开发者要维护这样的样式表将痛苦不堪。样式级联看着很美好,但控制样式也同样困难,比如某个开发改动了一部分样式,会导致其它部分也因此需要跑回归测试。这些问题似曾相识,也因此Sass(和更新的
顺着这个思路,我们用 PostCSS 来攥写模块化的 CSS 片段,并通过@import
将这些模块组合起来。虽然这需要花点精力配置webpack,但这对你来说不成问题!
无论你最终选择了哪种编译器,它们最终都会通过一个头部的<link>
标签,把所有的样式扔在一个 CSS文件内。随着应用日益复杂,这个文件将更加臃肿,异步加载将变得缓慢,从而阻塞了应用的其余部分的渲染(当然,阻塞渲染不总是是件坏事,但总体来说,我们还是会尽量避免使用会阻塞渲染的样式和脚本)。
我并不是说这种方式毫无可取之处。对于小应用来说,抑或对前端开发并不重视的团队们来讲,一张样式表足以满足需求了。它清晰地分离了业务逻辑和样式,而且它不是生成的,对开发者而言所写即所得,随心所欲。此外,浏览器也可以轻松缓存这张样式表,所以那些回头客们也就不用重新下载了。
而我们现在所寻找的,是一种能够完全发挥工具优势、稳健的 CSS架构。这种架构需要能通过一种精细的方式,管理整个应用:CSS模块化呼之欲出。
单张样式表一个严峻的问题是回归的风险。样式表内写一个模糊选择器样式可能会改动到另一个无关组件的样式。带作用域的样式此刻就发挥了其作用。
带作用域的样式可以程序化的生成对应组件的明确类名,以确保它们的类名唯一。自动生成的类名例如header__2lexd
,后面那小部分是选择器唯一的哈希值。当一个组件叫header 时,你可以给它的类名取名为 header,程序将自动生成类似header__15qy_
的新哈希后缀。
基于不同的实现方式,CSS模块生成类名的方式不尽相同,这部分我就不赘述了,请参考
到头来,在浏览器内我们仍然是用头部的<link>
标签来加载使用生成的单个 CSS文件。伴随而来的有潜在问题(诸如阻塞渲染、文件大小膨胀等),和上文提到的些许好处(缓存是主要优势)。一个需要注意的点是:这种方法移除了全局作用域—— 起码一开始没有,而这正是其样式作用域所致。
比如在一个应用内,你想将一个全局的类名.screen-reader-text
应用在任何一个组件上,当你使用 CSS模块化时,你得在 :global
伪选择器内定义样式,才能使得这个类样式能被其它组件引用到;接着你需要把这个带有全局选择器的文件导入到各个组件的样式表内,才能生效。这样做虽然不算麻烦,但还是得花点力气习惯这种做法。
这是一个使用 :global
伪选择器的范例:
1 | // typography.css |
你可能得冒险把一大摞的字体、表格和大部分页面都有的通用元素样式扔进这一个:global
选择器。幸好
1 | // main.scss |
像这样,把部分样式抽离出来,不再需要用 :global
伪选择器包装,只需要在主样式表中导入即可。
还有一点需要适应的是,在 DOM 节点中引用类名的方式。这点
1 | // ./css/Button.css |
CSS模块化有诸多精彩的用例。如果你在寻找一种带作用域的样式,又希望保留静态样式的优势,那么CSS 模块化正适合你。
同样值得注意的是,CSS 模块化可以和你喜爱的 CSS 预处理器相结合。通过CSS 模块化,诸如 Sass、Less、PostCSS 等 CSS预处理工具与插件都可以结合进项目构建过程中。
但是,假如你的应用程序是基于 JavaScript 开发的,那么如果 CSS样式也可以访问组件的各种状态,并根据状态的变化做出反应,也会是不错的路子。假设你希望轻松地将关键CSS 加入到应用程序中,有请 CSS-in-JS!
CSS-in-JS 这个话题颇为宽泛。也有一些库致力于轻松书写 CSS-in-JS。像
总体而言,这些框架大部分的实现方式是相通的。它们都会给单个组件写样式,并在构建过程中只编译页面上即将渲染的组件的CSS。CSS-in-JS 框架通过 <head>
内的<style>
标签输出 CSS,这种关键 CSS加载策略开箱即用,并且像 CSS模块化一样包含作用域,类名也经过了哈希。
当你在应用内跳转时,卸载的组件会把对应的样式从<head>
内移除,加载的组件会加上对应的样式,因此性能得到了提升。不再有 HTTP请求,也不会阻塞渲染,还确保了浏览器只会下载用户需要看到的样式。
有趣的是,CSS-in-JS 可以获取不同组件的状态和方法,借此渲染不同的CSS。它可以像基于状态改变而重复加减类名那样简单,也可以像制作一套主题那样复杂。
因为 CSS-in-JS 着实是热门话题,我知道许多人也有不同的实践。我对CSS-in-JS 的第一反应是十分负面的,我不喜欢 CSS 和 JS两者这个理念在一起交叉污染,但我还是想保持开放的心态,因此需要从前端开发者的角度来评估哪些功能是我们需要的。现在我将分享一些其他人的感受,这群人非常重视CSS,尤其是用 JS 写 CSS:
padding-left
变成paddingLeft
。这不是我个人想放弃的习惯。此外针对框架还有很多问题。但对于我们这些人来说,一生中大部分时间都在研究和实施我们喜爱语言的解决方案,我们要确保尽最大的可能把同样的语言写到最好。
下面是使用 Styled Components 的 React 组件:
1 | // ./Button.js |
我们还需要探索 CSS-in-JS 解决方案的潜在缺点 —— 绝对不是我加戏。使用CSS-in-JS,我们很容易落入另一个陷阱,日积月累写出一个组件里有几百行 CSS的臃肿的 JavaScript文件,让开发者难以辨别组件的方法和结构。但同时,我们可以非常仔细地检查我们如何以及为什么要如此构建组件。在更深入地思考这个问题时,我们可以利用它并编写更精简的代码和更多可重用的组件。
此外,此方法完全模糊了业务逻辑和应用程序样式之间的界限。但只要架构的文档完备且经过深思熟虑,项目中的其他开发人员便可以放心遵从这个想法而不会不知所措。
现在有各种各样的框架和方法可以在任何项目中解决 CSS架构问题。我们作为开发者,有如此之多的选择,是让人无比兴奋的。然而我们仍会在碎片化社交媒体中产生选择困难症,因为每个解决方案都有其优点和不足的缺点。归根结底,我们是在讨论如何仔细而周密地实现系统在未来可控,让未来的我们和开发人员们感谢自己曾花时间建立这个架构。
]]>发布于掘金 https://juejin.im/post/6867054761741549576
Enhancing Pre-trained Chinese Character Representation withWord-aligned Attention一文是我和组内同学、师兄的合作工作,作为短文录用于 ACL 2020。
说起来很奇妙,这个工作最开始是为了做 Aspect-extraction相关工作而开始的,效果很一般。但是在调参的时候发现单纯作为序列标注任务的一个额外的特征输入,居然得到了一丁点提升。也就是这一点点提升,我决定把它应用在预训练语言模型中做一做实验。在经过大量的试错、调整和调参后,最终得到了这么一种新奇的方法,可以让预训练语言模型额外获得一些word-level的信息,在各个需要词信息的任务中都有那么一点提升。但这个方法相当的实验化且缺乏理论支撑,并且还有一些别的致命问题(如果没有这些问题谁会去投短文...),会在后面一一说明。下文将结合在会上做远程汇报的slide,简单描述这个工作。
ppt 已经放在
这里了
反正就是想写个笔记给自己看,又不是写论文,就不用玩啥避重就轻之类的套路了,吐槽为主(反正没人看)。
首先是预训练语言模型在最近有了很大的发展,上面那个图是 thunlp组同学整理的。现在预训练语言模型发展方向就是在不断改进预训练任务和模型结构,让其能适配更大量的数据的数据,方便刷榜,看看GPT-3 那 1700亿个参数就心酸。当然也有许多做压缩模型、蒸馏的工作,这些现在应用起来反而更实用一些。还有一些工作在尝试融入额外信息,比如:清华nlp 提出的 ERNIE 在 BERT 中融入知识图谱;百度的 ERNIE 1.0融入实体信息,ERNIE 2.0 花式训练;香侬科技魔幻的 Glyce融合字形;创新工场的 ZEN 用 n-gram 去融合分词信息。
但是不管怎样边,主流的预训练语言模型都和上图一样,分预训练和微调两个阶段(GPT-3那种号称不用微调的除外),现在大家的主要工作也是集中在预训练阶段去做的。近些年这块最经典的工作当然非BERT 莫属了,所以我后面都是在 BERT 上跑实验。
不管啥模型,第一件事都是 tokenizer。对于 BERT 来说,英文的 token 是word-piece,中文的是字(这也对后面的实验造成了很大的麻烦,因为要对齐)。而且已经有相当多的工作证明了,对于中文在character-level 建模会比较合适(香侬在 ACL2019 的那篇《Is WordSegmentation Necessary for Deep Learning of ChineseRepresentations》很是经典)。不过在实际应用中,包括很多 Application ofNLP领域的文章,还有我自己的文章,都发现将词信息融入到文本表示中会对应用有效果。
所以,这篇论文实质上就是在实验看有什么办法去各种拐着弯儿向character-level 的表示模型融入词信息。
至于动机也很简单。玄一些就是把一些眼动追踪的研究挪过来建模:
[1] Reading spaced and unspaced chinese text: Evidence from eyemovements [2] Parafoveal load of word N+1 modulates preprocessingeffectiveness of word N+2 in Chinese reading [3] Cognitive mechanisms inreading ancient Chinese poetry: evidence from eye movements
上图就是上面几篇论文的部分结论,总结起来就是人阅读中文的时候对每个词付出的“注意度”类似。
实在一些就是想找一些方法来改变 transformer 的 attention分布,或者找一种可以折中 soft-attention 与 hard-attention的方法,在维持原 attention 机制的情况下,用比较 soft 的方法来实现比较hard 的效果,来方便某些任务(后记中有写)。
总之,我就是根据这些动机进行了实验。
(在师兄指导下画的图,还挺好看的)
模型很简单,就是在预训练语言模型对下游任务进行微调时,中间插上一层multi-head attention 的变体。
首先,可以使用分词工具将输入的文本进行分词,具体来说就是讲由字构成的序进行划分(parition),我们把这种划分策略称为\(\pi\)。
得到划分 \(\pi\)后,将其应用于正常得到的 attention权重矩阵上,可以得到按词划分的(word-based)字级别(character-level)的attention 权重组合。
为了同时考虑:1. 句子中所有词的语义表示;2.句子中最重要的词的语义表示 这两种情况,我们使用 mix-pooling 来对mean-pooling 和 max-pooling 进行混合:
\[MixPooling = \lambda MeanPooling + (1 - \lambda MaxPooling)\]
其中 \(\lambda\)为参数(后面做实验观察 \(\lambda\)发现,还是 MeanPooling 更重要一些)。
比如上图就是这种 attention权重矩阵的可视化效果图。这个例子是从情感分类任务模型中拿出来试的,可以看到attention 权重矩阵被转化为了 character-level to word-level的形式,而实际上还是 character-level的模型,保留了字建模的优秀表示,同时也做到了前面动机所说的接近hard-attention 的效果。
把这样的 attention 权重再拿回 character-level表示去调整它,就能得到最终的字表示,送往后续的下游任务。
然而,众所周知,分词器经常会出现问题。
上图是论文里的图(为了和平特意找了个都没分错的例子),这几个分词器得到的结果都是对的,但是其粒度不同。
为了减少分词错误,以及用上不同粒度级别的特征,我们找了一种简单的方法,同时用上多个分词工具的分词结果。
\[\textbf{H'} = \sum_{m=1}^{M} \tanh( {\textbf{H}}^m\textbf{W})\]
真的很简单,就是几个分词器的结果,分别得到下游表示之后过个线性层结合在一起而已。
实验证明这样是有一定效果的。
都在原文里有,没啥槽点,就是做实验耗的时间太多了。
总结一下这个工作的优缺点:
优点:
缺点:
总结下来,这个工作其实缺点其实挺明显的,主要集中在预处理和速度极慢这两块上。吐槽:但投稿时call for short paper 写明白了就是欢迎分享这些不是很完善的 idea呀,不懂为啥要使劲冲着缺陷打,没这些问题投长文不香吗?
优点主要还是这个结构足够新颖。由于这种东西的预处理实在太dirty了,跑起来也慢的令人抓狂,我是不打算 follow这个工作继续做下去了。但是,这种有意思的结构可以用在其它一些 NLP应用里面,还是可以做一做的。
在郁博文师兄的帮助下第一次写这种实验性质的短文也是挺有意思的。我受到的指导,和我写的文章,一般都是发现问题->分析问题->分析方案->理论支撑方案->实验支撑理论
这么个范式;而这篇文章是发现问题->分析问题->哇,有灵感了->实验结果还不错
这么个流程,还是蛮奇妙的。但说到底还是缺乏理论支撑,我去年曾尝试用离散数学去建模分词和这个模型的过程(有图为证),还试图用正则化
或者标准化
等深度学习术语来解释这种模型,但都成功地浪费了大量的时间,在没有理论支撑的情况下,也只能这样了。
这篇文章的录用还是很侥幸的。在审稿 rebuttal的时候,审稿人给的分和评价都很一般。正如前文所说,文本的确有很多问题,但几位审稿人最主要的关注点居然都主要集中在空间复杂度和训练参数数量上面,没有抓主要矛盾而是重点抓次要矛盾去了。所以简单回答这些关于参数、空间占用之类的问题值后,有位审稿人改了分,这才被录用。
]]>最后这篇论文出来的时候真是命运多舛,赶上了 2020年的疫情,不让回实验室,资料、代码啥的全在工位台式机上,又赶上组里的大工程和自己的毕设,只能抽空远程一点一点扒代码,扒到开会都没扒完;后来都有好几位老师同学发邮件索取了,都没办法直接发给人家可以直接跑的模型,只给一个老师发了最主要的那个
attention align
模块,也不知道有没有帮上他的忙;好在后来找了点办法能远程直连了,不然更难受。
--depth=1
命令clone,导致在本地追踪不到远程的分支,并且用 git branch -a
看不到远程分支,当然也不能 checkout 到 origin/remote上去。git fetch all
、git fetch origin
也都拿不到内容。因为有 slash 的内容和已经准备好的 commit,又不想重新去 clone,想起来git 使用 fetch 时就是去找 .git/config
文件里的 remoteorigin 字段,因此直接改了这个文件的内容:
1 | vim .git/config |
找到
1 | [remote "origin"] |
果然 head 和 remote origin 都指向 master,把 master 改成 *:
1 | [remote "origin"] |
接着git fetch --all
,就拿到了全部的分支,现在就可以直接去checkout 了~