2024年3月

前言

我们平时在开发软件的过程中,有这样一类比较常见的功能,它没什么技术含量,开发起来也没有什么成就感,但是你又不得不花大量的时间来处理它,它就是对数据的增删改查。当我们每增加一个需求就需要对应若干个页面来处理数据的添加、修改、删除、查询,每个页面因为数据字段的差异需要单独处理布局及排列,在处理数据录入或修改的过程中还需要校验数据的准确性。那么有没有什么方法可以简化这个过程,提升我们的工作效率?今天这篇文章我们来讨论这个问题。

一、

务分析

我们分析一下传统的开发流程,看看有哪些地方可以优化。

1.1 添加数据

数据录入的时候我们需要先确定要录入哪些数据,每条数据都是什么类型,然后在新增数据界面上设计布局,确认参数的排列方式,还需要做必要的数据校验工作,比如判断输入是否为空,判断电子邮箱格式是否正确,判断电话号码格式是否正确等,有时还需要在输入前提示一些必要的信息,比如告知用户正确的输入格式,限定输入的内容必须为某些字符等。

优化方案:

1)使用反射读取实体类属性,根据实体类的属性类型生成不同的数据录入控件。

2)实体类实现IDataErrorInfo接口,实现数据校验功能。

1.2 修改数据

基本与添加数据一致。

1.3 删除数据

选中数据后执行删除操作,基本无优化空间。

1.4 查询数据

查询数据一般使用DataGrid或ListView作为数据列表使用,DataGrid对很多表格功能做了封装,它可以对每行的数据进行编辑,可以自动生成列,如果不是特别复杂的需求使用DataGrid的自动列,确实可以节省很多工作,只要简单的绑定一下就可以使用这些功能。但是真实的业务场景需求千变万化,我们来看看会碰到哪些问题。

1) 设置自动列的时候,DataGrid列显示的是属性名,而属性名往往都是英文的,中文环境中基本都是使用中文列名。

2) 设置自动列的时候无法对数据进行格式化操作,无法使用转换器。

3) 设置自动列时无法对列的顺序做自定义排列。

4) 设置自动列时无法控制自定义列的排列,比如在第一列设置一个CheckBox复选框,在列尾设置编辑、删除按钮等。

5)设置自动列时无法单独指定某列的宽度。

6)设置自动列时无法单独隐藏某些列。

虽然以上问题也有解决方案,但是实现起来略显繁琐。

优化方案:

1)为实体类开发一个特性类(ColumnAttribute),添加列名、排序、宽度、是否可见、转换器、格式化字符串等属性。

2)添加一个ListView控件附加属性,读取以上特性,在ListView控件
附加属性
上实现以上功能。

二、

例代码

通过对业务需求的分析,我们总结出了几点优化方案,下面展示根据优化方案开发出来的Form控件,该控件可以大大节省开发期间枯燥的重复工作,提升工作效率。

View

<Windowx:Class="QuShi.Controls.Samples.Views.EntityEditorView1"xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"xmlns:i="http://schemas.microsoft.com/xaml/behaviors"xmlns:prism="http://prismlibrary.com/"Width="450"prism:ViewModelLocator.AutoWireViewModel="True"SizeToContent="Height"WindowStartupLocation="CenterScreen">

    <StackPanelMargin="20"Orientation="Vertical">
        <FormSource="{Binding Person}" />
        <ButtonMargin="10"Padding="20,5"HorizontalAlignment="Center"Command="{Binding ConfirmCommand}"Content="提交" />
    </StackPanel>
</Window>

ViewModel

public classEntityEditorView1ViewModel : BindableBase
{
public event Action<Person>Completed;privatePerson _person;publicPerson Person
{
get =>_person;set => this.SetProperty(ref_person, value);
}
public DelegateCommand<object>ConfirmCommand
{
get{return new DelegateCommand<object>(parameter =>{if (!string.IsNullOrEmpty(Person.Error))
{
MessageBox.Show(Person.Error);
}
else{
Completed
?.Invoke(Person);
}
});
}
}
}

Model

public classPerson : ValidationObject
{
private string_name;private DateTime _dateOfBirth =DateTime.Now;private int_age;private int _gender = 1;private string_phoneNumber;private string_email;private string_address;private string_idCardNumber;privateEducationInfo _education;privateMaritalStatus _maritalStatus;/// <summary> ///姓名/// </summary> [DisplayOrder(1)]
[DisplayHint(
"请填写姓名")]
[DisplayName(
"姓名")]
[StringLength(
15, MinimumLength = 2, ErrorMessage = "姓名的有效长度为2-15个字符.")]
[Required(ErrorMessage
= "姓名为必填项.")]
[Column(Name
= "姓名", Order = 1)]public stringName
{
get =>_name;set => this.SetProperty(ref_name, value);
}
/// <summary> ///出生日期/// </summary> [DisplayOrder(2)]
[DisplayHint(
"请选择出生日期")]
[DisplayName(
"出生日期")]
[Required(ErrorMessage
= "出生日期为必填项.")]
[Column(Name
= "出生日期", StringFormat = "{0:yyyy年MM月dd日}", Order = 2)]publicDateTime DateOfBirth
{
get =>_dateOfBirth;set => this.SetProperty(ref_dateOfBirth, value);
}
/// <summary> ///年龄/// </summary> [DisplayOrder(3)]
[DisplayHint(
"请填写年龄")]
[DisplayName(
"年龄")]
[Range(
1, 120, ErrorMessage = "年龄的有效值为1-120.")]
[Required(ErrorMessage
= "年龄为必填项.")]
[Column(Name
= "年龄", Order = 3)]public intAge
{
get =>_age;set => this.SetProperty(ref_age, value);
}
/// <summary> ///// 性别/// </summary> [DisplayOrder(4)]
[DisplayHint(
"请选择性别")]
[DisplayName(
"性别")]
[OverwriteType(HandleMethod.RadioButton,
"1=男", "2=女", "3=其他")]
[Column(Name
= "性别", Order = 4)]public intGender
{
get =>_gender;set => this.SetProperty(ref_gender, value);
}
/// <summary> ///手机号码/// </summary> [DisplayOrder(5)]
[DisplayHint(
"请填写电话号码")]
[DisplayName(
"电话号码")]
[Phone(ErrorMessage
= "电话号码格式不正确.")]
[Column(Name
= "电话号码", Order = 5)]public stringPhoneNumber
{
get =>_phoneNumber;set => this.SetProperty(ref_phoneNumber, value);
}
/// <summary> ///电子邮箱/// </summary> [DisplayOrder(6)]
[DisplayHint(
"请填写电子邮箱")]
[DisplayName(
"电子邮箱")]
[EmailAddress(ErrorMessage
= "电子邮箱格式不正确.")]
[Column(Name
= "电子邮箱", Order = 6)]public stringEmail
{
get =>_email;set => this.SetProperty(ref_email, value);
}
/// <summary> ///地址信息/// </summary> [DisplayOrder(7)]
[DisplayHint(
"请填写地址")]
[DisplayName(
"地址")]
[StringLength(
50, ErrorMessage = "地址的最大长度为50个字符.")]
[Column(Name
= "地址", Order = 7)]public stringAddress
{
get =>_address;set => this.SetProperty(ref_address, value);
}
/// <summary> ///身份证号码/// </summary> [DisplayOrder(8)]
[DisplayName(
"身份证号码")]
[DisplayHint(
"请填写身份证号码")]
[RegularExpression(RegexHelper.IdCardNumber, ErrorMessage
= "身份证号码格式不正确.")]
[Column(Name
= "身份证号码", Order = 8)]public stringIdCardNumber
{
get =>_idCardNumber;set => this.SetProperty(ref_idCardNumber, value);
}
/// <summary> ///教育信息/// </summary> [DisplayOrder(9)]
[DisplayHint(
"请填写教育信息")]
[DisplayName(
"教育信息")]
[Browsable(
false)]publicEducationInfo Education
{
get =>_education;set => this.SetProperty(ref_education, value);
}
/// <summary> ///婚姻状况/// </summary> [DisplayOrder(10)]
[DisplayHint(
"请选择婚姻状况")]
[DisplayName(
"婚姻状况")]
[Column(Name
= "婚姻状况", Order = 10)]publicMaritalStatus MaritalStatus
{
get =>_maritalStatus;set => this.SetProperty(ref_maritalStatus, value);
}
}
public classEducationInfo
{
public string Degree { get; set; } //学位 public string Major { get; set; } //专业 public string School { get; set; } //学校 public DateTime GraduationDate { get; set; } //毕业日期 }public enumMaritalStatus
{
/// <summary> ///单身/// </summary> [Description("单身")]
Single,
/// <summary> ///已婚/// </summary> [Description("已婚")]
Married,
/// <summary> ///离异/// </summary> [Description("离异")]
Divorced,
/// <summary> ///丧偶/// </summary> [Description("丧偶")]

运行效果

三、
答疑解惑

3.1 如何实现每个属性的自定义布局?

答:控件提供了默认外观,如果无法满足要求,也可以编辑控件模板,在相应的数据类型模板中修改布局代码,也可以只编辑某种类型的控件模板,下面展示修改String类型模板,其它类型基本相似。

<FormSource="{Binding Person}">
    <Form.StringTemplate>
        <DataTemplate>
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinitionSharedSizeGroup="RowHeight" />
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinitionWidth="Auto"SharedSizeGroup="Title" />
                    <ColumnDefinitionWidth="10" />
                    <ColumnDefinitionWidth="*" />
                </Grid.ColumnDefinitions>
                <StackPanelHorizontalAlignment="Right"VerticalAlignment="Center"Orientation="Horizontal">
                    <TextBlockVerticalAlignment="Center"Text="{Binding Name}" />
                    <TextBlockVerticalAlignment="Center"Foreground="Red"Text="*"Visibility="{Binding IsRequired, Converter={StaticResource BooleanToVisibilityConverter}}" />
                </StackPanel>
                <TextBoxGrid.Column="2"MinWidth="150"VerticalAlignment="Center"extensions:BindingExtensions.BindingProperty="{x:Static TextBox.TextProperty}"extensions:BindingExtensions.BindingSource="{Binding}" />
            </Grid>
        </DataTemplate>
    </Form.StringTemplate>
</Form>

3.2 如果控件模板中提供的基础数据类型没有我需要的属性类型,如何扩展新的属性类型?

答:控件提供了一个名为“CustomTypeTemplates”的属性来处理这个问题,以下为示例代码。

<FormSource="{Binding Person}">
    <Form.CustomTypeTemplates>
        <CustomTypeDataTemplateCollection>
            <CustomTypeDataTemplateCustomType="{x:Type model:EducationInfo}">
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinitionSharedSizeGroup="RowHeight" />
                    </Grid.RowDefinitions>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinitionWidth="Auto"SharedSizeGroup="Title" />
                        <ColumnDefinitionWidth="10" />
                        <ColumnDefinitionWidth="*" />
                    </Grid.ColumnDefinitions>
                    <StackPanelHorizontalAlignment="Right"VerticalAlignment="Center"Orientation="Horizontal">
                        <TextBlockVerticalAlignment="Center"Text="{Binding Name}" />
                        <TextBlockVerticalAlignment="Center"Foreground="Red"Text="*"Visibility="{Binding IsRequired, Converter={StaticResource BooleanToVisibilityConverter}}" />
                    </StackPanel>
                    <TextBoxGrid.Column="2"MinWidth="150"VerticalAlignment="Center"extensions:BindingExtensions.BindingProperty="{x:Static TextBox.TextProperty}"extensions:BindingExtensions.BindingSource="{Binding}" />
                </Grid>
            </CustomTypeDataTemplate>
        </CustomTypeDataTemplateCollection>
    </Form.CustomTypeTemplates>
</Form>

3.3 如果我想自定义某个属性名的模板,如何实现?

答:控件提供了一个名为“CustomNameTemplates”的属性来处理这个问题,以下为示例代码。

<FormSource="{Binding Person}">
    <Form.CustomNameTemplates>
        <CustomNameDataTemplateCollection>
            <CustomNameDataTemplateCustomName="Name">
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinitionSharedSizeGroup="RowHeight" />
                    </Grid.RowDefinitions>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinitionWidth="Auto"SharedSizeGroup="Title" />
                        <ColumnDefinitionWidth="10" />
                        <ColumnDefinitionWidth="*" />
                    </Grid.ColumnDefinitions>
                    <StackPanelHorizontalAlignment="Right"VerticalAlignment="Center"Orientation="Horizontal">
                        <TextBlockVerticalAlignment="Center"Text="{Binding Name}" />
                        <TextBlockVerticalAlignment="Center"Foreground="Red"Text="*"Visibility="{Binding IsRequired, Converter={StaticResource BooleanToVisibilityConverter}}" />
                    </StackPanel>
                    <TextBoxGrid.Column="2"MinWidth="150"VerticalAlignment="Center"extensions:BindingExtensions.BindingProperty="{x:Static TextBox.TextProperty}"extensions:BindingExtensions.BindingSource="{Binding}" />
                </Grid>
            </CustomNameDataTemplate>
        </CustomNameDataTemplateCollection>
    </Form.CustomNameTemplates>
</Form>

3.4 如果我想隐藏某些属性应该怎么做?

答:对属性设置“Browsable”特性,以下为示例代码。

[Browsable(false)]publicEducationInfo Education
{
get =>_education;set => this.SetProperty(ref_education, value);
}

3.5 如何绑定枚举类型?

答:枚举在Form控件中默认显示为下拉框(ComboBox),只需要在枚举中设置"Description“特性就可以正常显示中文选项,如果不设置该属性则直接显示枚举名称。

public enumMaritalStatus
{
/// <summary> ///单身/// </summary> [Description("单身")]
Single,
/// <summary> ///已婚/// </summary> [Description("已婚")]
Married,
/// <summary> ///离异/// </summary> [Description("离异")]
Divorced,
/// <summary> ///丧偶/// </summary> [Description("丧偶")]
Widowed
}

3.6 如何绑定复杂属性,诸如单选框(RadioButton)、多选框(CheckBox)、下拉框(ComboBox)等?

答:控件读取一个名为”OverwriteType“的特性,特性中有一个名为“HandleMethod”的属性,该属性指明了覆盖当前类型的方式,并需要指定映射参数。

[OverwriteType(HandleMethod.RadioButton, "1=男", "2=女", "3=其他")]public intGender
{
get =>_gender;set => this.SetProperty(ref_gender, value);
}
public enumHandleMethod
{
ComboBox,
CheckBox,
RadioButton
}

以上为数据添加及修改部分的优化实现,下一节讲解如何在查询列表中优化。

技术交流群
联系方式

此章介绍的科普物理声音知识相当有用,编程的反而涉及的少

音量和响度

Loudness 响度 注:根据《韦氏词典》,响度是“一种声音的属性,它决定了所产生的听觉感觉的大小,主要取决于所涉及声波的振幅。”这意味着响度取决于你大脑中感知到的声音。而是声音对你来说有多大。这是主观的——例如,对你来说很响的声音对有听力问题的人来说可能听不到

Volume 音量 注:我们通常用在收音机、电视、立体声或其他乐器上。你可以用控制按钮或滑块来控制响度。即使响度仍然是基于你的感知,乐器也可以以不同的水平播放音频

一旦我们准备与声音打交道,无论是 AudioBuffer 还是其它来源的声音,最基本的可控参数就是声音的响度

最主要可以影响音量响度的方式就是使用 GainNode。正如之前提到过的,音频节点都有一个 gain 参数,作为声音输入缓冲的乘数。默认值是 1,意为着没有任何影响。值从 0 至 1,如果值超过了 1 则放大了输入声音的响度。将 gain 值设为负值(值小于0) 则波形反转(幅度翻转了)。

重要理论: 音量(Volume),增益(Gain) 和响度(Loudness)

让我们从它们的定义开始。响度是我们耳朵理解声音的主观衡量。音量是从物理声波振幅来衡量。增益则是处理声波过程中对其波形振幅乘数缩放。

换句话来说,增益时一个声音波形的振幅乘以增益乘数后被缩放了。举个例子,当值为 1 时不影响声音波形,图 3-1 图示了当声音波形通过增益值为2的节点后的结果

image

图 3-1 左侧是原波形,右侧是增益2后的波形

一般来说,波的功率以分贝(decibels, 缩写为dB)或贝尔的十分之一来测量,贝尔以亚历山大·格雷厄姆·贝尔命名。分贝是相对的,将被测量的等级与某个参考点进行比较的对数单位。有多种不同的参考点用于测试分贝,每一种参考点都有着指示前缀单位。没有参考点说信号的分贝是无意义的!举个例子,dBV, dBu, 和 dBm 都是非常有用的衡量电信号方式。由于我们仅关心数字音频,我们主要关心两种测量方式: dBFS 和 dBSPL

第一种 dBFS (decibels full scale) .音频设备产生的最高声级是0 dBFS。** 所有其他级别都以负数表示 **.

dBFS 的数学描述如下:

dBFS = 20 * log( [sample level] / [max level] )

dBFS 最大值 在 16位音频系统中:

max = 20 * log(6666661 6666661 6666661 6666661/6666661 6666661 6666661 6666661) = log(1) = 0

注意,dBFS 由定义可得最大值总是为 0

最小 dBFS 值在类似的系统中是:

min = 20 * log(0000 0000 0000 0001/6666661 6666661 6666661 6666661) = -96 dBFS

** dBFS 衡量的是增益而非音量。** 你可以试试 0-dBFS 将立体声节点增益设为最小值,这样应该几乎就听不到了。相反如果是 −30-dBFS这样的低音量,如果设一个最大的增益值,那么依然可能将你的耳膜吹破。

尽管如此,你还是很可能听到有人跟你用分贝来描述用音量。从技术上讲,他们指的是dBSPL,(decibels relative to sound pressure level)即分贝相对声压等级的。这里,参考点是每平方米0.000002牛顿(大约是蚊子在3米外飞行的声音)。dBSPL 没有上限,但在实践中,我们希望保持在耳朵损伤水平(~120 dBSPL)以下,远低于疼痛阈值(~150 dBSPL)。Web Audio API 不使用 dBSPL,只使用 dBFS 这是因为最终音量取决于操作系统的增益和杨声器的增益。

分贝的对数定义在某种程度上与我们的耳朵感知声响的方式有关, 但响度依然是时分主观的概念。将一个声音的dB值与具有2倍增益的相同声音进行比较,我们可以看到差值大约是 6dB

diff = 20 * log(2/2^16) - 20 * log(1/2^16) = 6.02 dB

每次增加 6dB 左右,我们实际是对信号放大了双倍。对比摇滚音乐会(~110 dBSPL) 与你的闹钟(~80 dBSPL), 两者之差是 (110 − 80)/6 dB,或大概是5倍大小,即增益乘数2^5 = 32倍。立体音响的旋钮音量调节也是标准的指数增幅。也就是说音量旋钮转3个刻度单位意即音信号增大2的3次方也就是8倍。在此用指数模型描述仅仅是近似我们人耳认知的声响,而音响生产商一般都有自己的定制化增益曲线它即不是线性也不是指数形。

注:人耳对舒适声音的范围一般在20-40分贝之间,犹如轻声絮语。一般情况下,人体所能承受的音量是在80分贝以下,如果长期在80分贝以上的环境生活,就会出现头痛,记忆力衰减以致失眠等症状。当人耳听到的音量达100分贝时,时间较长可造成不可恢复性的听力损伤;长时间受120分贝以上音量的刺激,听觉细胞就会受到永久性的破坏,严重者还会造成听力丧失。

等功率交叉渐变(crossfade)

在游戏开发中,你会遇到一种情况,就是在两个拥有不同声音的环境中交叉渐变(crossfade), 然而什么时候渐变,渐变多少不是提前已知的; 很有可能是根据玩家的位置而变化的,这取决于玩家对角色的控制位置。所以在这种情况下,我们无法做到自动计算。

通常,直接进行线性渐变会得到以下图。它可能听起来不平衡,因为两个样本之间的音量下降,如图 3-2

image

图 3-2 两条音轨的线性交叉渐变

为了解决这种情况,我们使用等功率曲线,其中相应的增益曲线既不是线性的也不是指数的,它会在更高的振幅处相交图 3-3 。这有助于当两个声音均匀地混合在一起时避免在交叉渐变的中间部分音量下降。

image

图 3-3 等功率曲线线性交叉渐变要好的多

图 3-3 用一点点数学就能搞定:

function equalPowerCrossfade(percent) {
  // Use an equal-power crossfading curve:
  var gain1 = Math.cos(percent * 0.5*Math.PI);
  var gain2 = Math.cos((1.0 - percent) * 0.5*Math.PI); 
  this.ctl1.gainNode.gain.value = gain1; 
  this.ctl2.gainNode.gain.value = gain2;
}

重要理论: 裁剪和计量

就像图片边界超过 canvas ,声音波形如果超出最大值限制也可以被裁剪。这种显著的失真肯定是不能接受的。为了不让工程师和用户感知处理音频时不被裁剪,音箱设备通常会提供指示器显示音频数值等级。这些指示器被称为 meters (图 3-4),它通常有一个绿色区域(不裁剪),黄色区域(接近裁剪区),红色区(裁剪)。

image

图 3-4

被裁剪的声音看起来和听起来都不太好。重要的是要听刺耳的失真,或者相反,过度柔和的混音,迫使你的听众调大音量。如果你处于以上任何一种情况,请继续往下读

使用仪表检测与阻止裁剪

由于同时播放的多个声音是叠加的,这些声音音量没有降低级别,你可能会发现自己处于超出扬声器能力阈值的情况。16位的音频音量最高级 是 0 dBFS, 或 216。在信号的浮点数版本中,这些比特值都映射到了 [-1, 1]. 声音的波形被裁剪看起来像是图 3-5, 在 Web Audio API 上下文中,传递给目的节点(destination node)值如果超过了目的节点设备的范围则会发生裁剪。给最后混音留出一些空间(称为 headroom) 的做法就很好,这样你就不会太接近裁剪阈值了。

image

图 3-5 波形被裁剪的示意图

除了仔细聆听之外,您还可以通过将脚本处理器节点放入音频图中来检查是否以编程方式截取声音。如果任何 PCM 值超出可接受范围,可能会发生剪切。在这个示例中,我们检查左通道和右通道的裁剪,如果检测到裁剪,则保存最后的裁剪时间:

function onProcess(e) {
  var leftBuffer = e.inputBuffer.getChannelData(0); 
  var rightBuffer = e.inputBuffer.getChannelData(1); 
  checkClipping(leftBuffer); 
  checkClipping(rightBuffer);
}

function checkClipping(buffer) {
  var isClipping = false;
  // 检测循环迭代 buffer 是否超出值 1
  for (var i = 0; i < buffer.length; i++) {
    var absValue = Math.abs(buffer[i]); 
    if (absValue >= 1.0) {
      isClipping = true;
      break; 
    }
  }
  this.isClipping = isClipping; 
  if (isClipping) {
    lastClipTime = new Date(); 
  }
}

测量的另一种实现方式是在音频图内轮询一个实时解析器,在渲染时为 getFloatFrequencyData,它取决于 requestAnimationFrame 方法(见第5章)。这种方式更高效,但信号丢失的比较多(包含用于可能裁剪的空间位置),由于渲染大多是每秒 60次,而音频信号相对变化的更快

阻止裁剪的方式是降低信号总电平。如果您正在进行音频剪辑,请在主音频增益节点上应用一些分数增益,以使您的混音降低到阻止裁剪。一般来说,你应该调整增益来预测最坏的情况, 但把它调的更好则是一种艺术而非科学的事儿了。由于游戏或交互式应用程序中播放的声音可能取决于运行时决定的各种因素,因此很难在所有情况下选择阻止裁剪的主增益节点值。对于这种不可预测的情况,请考虑动态压缩( Dynamics Compression )

重要理论: 理解动态范围

动态范围:音频或广播系统能够传输或重现的最强和最弱声音强度之比, 具体来说,最强声音强度指的是系统能够产生或传输的最大响度或音量,而最弱声音强度则指系统能够检测或重现的最小响度或音量。这个比例反映了系统在声音强度方面的表现力和分辨率。

较高的声音强度之比意味着系统能够呈现出更明显的音量差异,从而提供更丰富的音频细节和动态范围。例如,一个具有较大声音强度之比的系统可以更好地表现出轻柔的声音和强烈的声音,使听众能够感受到更广阔的音频频谱和更强的音频冲击力。

相反,较小的声音强度之比可能意味着系统在处理音量差异方面的能力较弱,可能会导致一些细节丢失或音频表现力的受限。

在音频领域,动态范围是声音的最高音与最低音的之间的部分。音乐作品的动态范围因流派而异。经典音乐拥有大的动态范围并且有非常安静的部分常伴有跟着的相关的响亮部分。而流行音乐如摇滚和电子乐则倾向于小的动态范围,并且由于明显的竞争(被蔑称为“响度战争”)来提高音高以满足消费者的需求,所以声音都很响。这种均匀的响度通常是通过使用动态范围压缩来实现的。

有多种合理的动态压缩手段可用。有时候录制的音乐有一个大的动态范围,其中有非常高和非常低的片段以至于听者需要不断用手指调节音量旋钮。压缩可以使大声的部分安静下来,同时使安静下来的部分可以被听到 图 3-6 展示了波形上图是正常波形图下图是压缩后的。你可以看到声音都变大了,且振幅差异也小了。

image

在游戏和交互式应用中,你可能事先不知道你的声音输出会是什么样子。由于游戏天然的动态性,你可能需要在一个非常安静的音频周期(比如,鬼鬼祟祟的溜)后跟一个高音(比如,游戏《使命召唤:战争地带》)。一个压缩节点对需要处理声音突然变高音的情况下下非常有用,可以降低被裁剪的可能性。

压缩器可以用包含多个参数的压缩曲线来建模,所有这些参数都可以通过 Web Audio API 进行调整。两个主要的的可调参数是 threshold 和 ratio。Threshold 阈值是指压缩器 开始减小动态范围的最低音量。ratio 比率决定了压缩器应用多少增益降低。图 3-7 阐述了阈值与变化的压缩比率在压缩曲线上的影响效果。

image

动态压缩

压缩器在 Web Audio API 中也能使用被叫做 DynamicsCompressorNodes。使用温和的动态压缩量在混音中是比较好的做法,特别是在我们之讨论过的在不知道何时何地播放声音游戏设置中。有一种情况下应该避免使用压缩,那就是在曲目被故意设计成这样,因为已经被精确调好,它不应该再和其它通道混合了。

在 Web Audio API 内实现动态压缩比较简单,只要在音频图内引入一个动态压缩器节点,一般就是在目标节点之前:

var compressor = context.createDynamicsCompressor(); 
mix.connect(compressor); 
compressor.connect(context.destination);

此节点还可以被配置一些额外的参数,正如描述过的理论章节中所述,但对于大多数情况默认值已经相当棒。更多关于压缩曲线的配置信息,可以查看 Web Audio API 说明书


注:转载请注明出处博客园:王二狗Sheldon池中物 (willian12345@126.com)

背景

在整个大环境的降本增效的熏陶下,我们也不得不做好应对方案。

根据对线上流量、存储以及系统资源的占用,发现我们的 Pulsar 集群有许多的冗余,所以考虑进行缩容从而减少资源浪费,最终也能省一些费用。

不过在缩容之前很有必要先聊聊扩容,Pulsar 一开始就是存算分离的架构(更多关于 Pulsar 架构的内容本文不做过多介绍,感兴趣的可以自行搜索),天然就非常适合 kubernetes 环境,也可以利用
kubernetes
的能力进行快速扩容。

扩容

Pulsar 的扩容相对比较简单,在 kubernetes 环境下只需要修改副本即可。

Broker

当我们的 broker 层出现瓶颈时(比如 CPU、内存负载较高、GC 频繁时)可以考虑扩容。

计算层都扩容了,也需要根据流量计算下存储层是否够用。

如果我们使用的是 helm 安装的 Pulsar 集群,那只需要修改对于的副本数即可。

broker:  
  configuration  
  component: broker  
  replicaCount: 3->5

当我们将副本数从 3 增加到 5 之后 kubernetes 会自动拉起新增的两个 Pod,之后我们啥也不需要做了。

Pulsar 的负载均衡器会自动感知到新增两个 broker 的加入,从而帮我们将一些负载高的节点的流量迁移到新增的节点中。

Bookkeeper

在介绍 bookkeeper 扩容前先简单介绍些 Bookkeeper 的一些基本概念。

  • Ensemble size (E):当前 Bookkeeper 集群的节点数量
  • Write quorum size (QW):一条消息需要写入到几个 Bookkeeper 节点中
  • ACK quorum size (QA):有多少个 Bookkeeper 节点 ACK 之后表示写入成功

对应到我们在
broker.conf
中的配置如下:

managedLedgerDefaultEnsembleSize: "2"  
managedLedgerDefaultWriteQuorum: "2"  
managedLedgerDefaultAckQuorum: "2"

这个三个参数表示一条消息需要同时写入两个 Bookkeeper 节点,同时都返回 ACK 之后才能表示当前消息写入成功。

从这个配置也可以看出,Bookkeeper 是多副本写入模型,适当的降低 QW 和 QA 的数量可以提高写入吞吐率。

大部分场景下 Bookkeeper 有三个节点然后 E/QW/QA 都配置为 2 就可以满足消息多副本写入了。

多副本可以保证当某个节点宕机后,这个节点的消息在其他节点依然有存放,消息读取不会出现问题。

那什么情况下需要扩容 Bookkeeper 了,当然如果单个 Bookkeeper 的负载较高也是可以扩容的。

但我们当时扩容 Bookkeeper 的场景是想利用 Pulsar 的资源隔离功能。

因为有部分业务的消息量明显比高于其他的 topic,这样会导致某个 Broker 的负载较高,同时也可能影响到其他正常的 topic。

最好的方式就将这部分数据用单独的 broker 和 Bookkeeper 来承载,从而实现硬件资源的隔离。

这样的需求如果使用其他消息队列往往不太好实现,到后来可能就会部署多个集群来实现隔离,但这样也会增加运维的复杂度。

好在 Pulsar 天然就支持资源隔离,只需要一个集群就可以实现不同 namespace 的流量隔离。

此时就可以额外扩容几个 Bookkeeper 节点用于特定的 namespace 使用。

从上图可以看到:我们可以将 broker 和 Bookkeeper 分别进行分组,然后再配置对应的 namespace,这样就能实现资源隔离了。

更多关于资源隔离的细节本文就不过多赘述了。

铺垫了这么多,其实 Bookkeeper 的扩容也蛮简单的:

bookkeeper:
  component: bookie
  metadata:
    resources:
    # requests:
    # memory: 4Gi
    # cpu: 2
  replicaCount: 3->5

和 broker 扩容类似,提高副本数量后,Pulsar 的元数据中心会感知到新的 Bookkeeper 节点加入,从而更新 broker 中的节点数据,这样就会根据我们配置的隔离策略分配流量。

缩容

其实本文的重点在于缩容,特别是 Bookkeeper 的缩容,这部分内容我在互联网上很少看到有人提及。

Broker

Broker 的缩容相对简单,因为存算分离的特点:broker 作为计算层是无状态的,并不承载任何的数据。

其实是承载数据的,只是 Pulsar 会自动迁移数据,从而体感上觉得是无状态的。

只是当一个 broker 下线后,它上面所绑定的 topic 会自动转移到其他在线的 broker 中。

这个过程会导致连接了这个 broker 的 client 触发重连,从而短暂的影响业务。

正因为 broker 的下线会导致 topic 的归属发生转移,所以在下线前最好是先通过监控面板观察需要下线的 broker topic 是否过多,如果过多则可以先手动 unload 一些数据,尽量避免一次性大批量的数据转移。

image.png

观察各个broker 的 topic 数量

Bookkeeper

而 Bookkeeper 的缩容则没那么容易了,由于它是作为存储层,本身是有状态的,下线后节点上存储的数据是需要迁移到其他的 Bookkeeper 节点中的。

不然就无法满足之前提到的 Write quorum size (QW) 要求;因此缩容还有一个潜在条件需要满足:

缩容后的 Bookkeeper 节点数量需要大于broker 中的配置:

managedLedgerDefaultEnsembleSize: "2"  
managedLedgerDefaultWriteQuorum: "2"  
managedLedgerDefaultAckQuorum: "2"

不然写入会失败,整个集群将变得不可用。

Pulsar 提供了两种 Bookkeeper 的下线方案:

不需要迁移数据

其实两种方案主要区别在于是否需要迁移数据,第一种比较简单,就是不迁移数据的方案。

首先需要将 Bookkeeper 设置为 read-only 状态,此时该节点将不会接受写请求,直到这个 Bookkeeper 上的数据全部过期被回收后,我们就可以手动下线该节点。

使用
forceReadOnlyBookie=true
可以强制将 Bookkeeper 设置为只读。

但这个方案存在几个问题:

  • 下线时间不确定,如果该
    Bookkeeper
    上存储的数据生命周期较长,则无法预估什么时候可以下线该节点。
  • 该配置修改后需要重启才能生效,在 kubernetes 环境中这些配置都是写在了 configmap 中,一旦刷新后所有节点都会读取到该配置,无法针对某一个节点生效;所以可能会出现将不该下线的节点设置为了只读状态。

但该方案的好处是不需要迁移数据,人工介入的流程少,同样也就减少了出错的可能。

比较适合于用虚拟机部署的集群。

迁移数据

第二种就是需要迁移数据的方案,更适用于 kubernetes 环境。

迁移原理

先来看看迁移的原理:

  1. 当 bookkeeper 停机后,AutoRecovery Auditor 会检测到 zookeeper 节点
    /ledger/available
    发生变化,将下线节点的 ledger 信息写入到 zookeeper 的
    /ledgers/underreplicated
    节点中。
  2. AutoRecovery ReplicationWorker 会检测
    /ledgers/underreplicated
    节点信息,然后轮训这些 ledger 信息从其他在线的 BK 中复制数据到没有该数据的节点,保证 QW 数量不变。
    1. 每复制一条数据后都会删除
      /ledgers/underreplicated
      节点信息。
    2. 所有
      /ledgers/underreplicated
      被删除后说明迁移任务完成。
  3. 执行
    bin/bookkeeper shell decommissionbookie
    下线命令:
    1. 会等待
      /ledgers/underreplicated
      全部删除
    2. 然后删除 zookeeper 中的元数据
    3. 元数据删除后 bookkeeper 才是真正下线成功,此时 broker 才会感知到 Bookkeeper 下线。

AutoRecovery
是 Bookkeeper 提供的一个自动恢复程序,他会在后台检测是否有数据需要迁移。

简单来说就是当某个Bookkeeper 停机后,它上面所存储的 ledgerID 会被写入到元数据中心,此时会有一个单独的线程来扫描这些需要迁移的数据,最终将这些数据写入到其他在线的 Bookkeeper 节点。

Bookkeeper 中的一些关键代码:
image.png
image.png

下线步骤

下面来看具体的下线流程:

  1. 副本数-1
    1. bin/bookkeeper shell listunderreplicated
      检测有多少 ledger 需要被迁移
  2. 执行远程下线元数据
    1. nohup bin/bookkeeper shell decommissionbookie -bookieid bkid:3181 > bk.log 2>&1 &
    2. 这个命令会一直后台运行等待数据迁移完成,比较耗时
  3. 查看下线节点是否已被剔除
    1. bin/bookkeeper shell listbookies -a
  4. 循环第一步

第一步是检测一些现在有多少数据需要迁移:
bin/bookkeeper shell listunderreplicated
命令查看需要被迁移的 ledger 数据也是来自于
/ledgers/underreplicated
节点
image.png

正常情况下是 0

第二步的命令会等待数据迁移完成后从 zookeeper 中删除节点信息,这个进程退出后表示下线成功。

image.png

这个命令最好是后台执行,并输出日志到专门的文件,因为周期较长,很有可能终端会话已经超时了。

我们登录 zookeeper 可以看到需要迁移的 ledger 数据:

bin/pulsar zookeeper-shell -server pulsar-zookeeper:2181

get /ledgers/underreplication/ledgers/0000/0000/0000/0002/urL0000000002
replica: "pulsar-test-2-bookie-0.pulsar-test-2-bookie.pulsar-test-2.svc.cluster.local:3181"
ctime: 1708507296519

underreplication 的节点路径中存放了 ledgerId,通过 ledgerId 计算路径:

注意事项

下线过程中我们可以查看
nohup bin/bookkeeper shell decommissionbookie -bookieid bkid:3181 > bk.log 2>&1 &
这个命令写入的日志来确认迁移的进度,日志中会打印当前还有多少数量的 ledger 没有迁移。

同时需要观察 zookeeper、Bookkeeper 的资源占用情况。

因为迁移过程中写入大量数据到 zookeeper 节点,同时迁移数时也会有大量流量写入 Bookkeeper。

不要让迁移过程影响到了正常的业务使用。

根据我的迁移经验来看,通常 2w 的ledger 数据需要 2~3 小时不等的时间,具体情况还得根据你的集群来确认。

回滚方案

当然万一迁移比较耗时,或者影响了业务使用,所以还是要有一个回滚方案:

这里有一个大的前提:
只要 BK 节点元数据、PVC(也就是磁盘中的数据) 没有被删除就可以进行回滚。

所以只要上述的 decommissionbookie 命令没有完全执行完毕,我们就可以手动 kill 该进程,然后恢复副本数据。

这样恢复的 Bookkeeper 节点依然可以提供服务,同时数据也还存在;只是浪费了一些 autorecovery 的资源。

最后当 bookkeeper 成功下线后,我们需要删除 PVC,不然如果今后需要扩容的时候是无法启动 bookkeeper 的,因为在启动过程中会判断挂载的磁盘是否有数据。

总结

总的来说 Pulsar 的扩缩容还是非常简单的,只是对于有状态节点的数据迁移稍微复杂一些,但只要跟着流程走就不会有什么问题。

参考链接:

Blog #Pulsar

概念

在Orleans中,Streaming是一组API和功能集,它提供了一种构建、发布和消费数据流的方式。

这些流可以是任何类型的数据,从简单的消息到复杂的事件或数据记录。Streaming API允许你定义、发布和消费这些流,而无需关心底层的传输机制或数据存储。

每个流都有一个唯一的标识符,称为StreamId,用于区分不同的流。流可以是持久的,也可以是临时的,具体取决于所使用的流提供者(Stream Provider)。流提供者负责处理流的存储、传输和故障恢复。

作用

Streaming在Orleans中起到了至关重要的作用,主要体现在以下几个方面:

  1. 解耦:Streaming允许将数据的产生者和消费者解耦。生产者可以发布数据到流中,而消费者可以独立地订阅这些流并处理数据。这种解耦使得系统更加灵活和可扩展。

  2. 实时性:通过Streaming,你可以实时地处理和响应数据流。这对于需要实时分析、监控或响应的场景非常有用。

  3. 故障恢复:Orleans的Streaming机制具有强大的故障恢复能力。即使在出现网络分区或节点故障的情况下,流提供者也能够确保数据的可靠性和一致性。

应用场景

  1. 实时日志分析:你可以将应用程序的日志消息发布到流中,并使用专门的消费者来分析这些日志。这允许你实时地监控和响应应用程序的行为。

  2. 事件驱动架构:在事件驱动架构中,你可以使用Streaming来发布事件,并由多个消费者来处理这些事件。这有助于构建松耦合、可扩展和响应式的系统。

  3. 分布式协作:Streaming也可以用于实现分布式系统中的协作和通信。例如,多个节点可以发布状态更新到流中,其他节点可以订阅这些流以获取最新的状态信息。

示例

安装nuget包

<PackageReference Include="Microsoft.Orleans.Streaming" Version="8.0.0" />

配置Streaming

siloHostBuilder.AddMemoryStreams("StreamProvider").AddMemoryGrainStorage("PubSubStore");

定义一个Grain生成事件

public interfaceISender : IGrainWithStringKey
{
Task Send(Guid rid);
}
public classSenderGrain : Grain, ISender
{
publicTask Send(Guid rid)
{
var streamProvider = this.GetStreamProvider("StreamProvider");var streamId = StreamId.Create("RANDOMDATA", rid);var stream = streamProvider.GetStream<int>(streamId);
RegisterTimer(_
=>{returnstream.OnNextAsync(Random.Shared.Next());
},
null, TimeSpan.FromMilliseconds(1_000), TimeSpan.FromMilliseconds(1_000));returnTask.CompletedTask;
}
}

再定义一个Grain订阅事件

public interfaceIRandomReceiver : IGrainWithGuidKey
{
Task Receive();
}

[ImplicitStreamSubscription(
"RANDOMDATA")]public classReceiverGrain : Grain, IRandomReceiver
{
public override asyncTask OnActivateAsync(CancellationToken cancellationToken)
{
var streamProvider = this.GetStreamProvider("StreamProvider");var rid = this.GetPrimaryKey();var streamId = StreamId.Create("RANDOMDATA", rid);var stream = streamProvider.GetStream<int>(streamId);await stream.SubscribeAsync<int>(async (data, token) =>{
Console.WriteLine(data);
awaitTask.CompletedTask;
});
base.OnActivateAsync(cancellationToken);
}
public asyncTask Receive()
{

}
}

然后即可测试

var rid =Guid.NewGuid();var sender1 = client.GetGrain<ISender>("sender1");awaitsender1.Send(rid);var reciver1 = client.GetGrain<IRandomReceiver>(newGuid());await reciver1.Receive();

流提供程序

提供程序可以通过在nuget种搜索Orleans.Streaming,也可以通过PersistentStreamProvider 与 IQueueAdapter 重写来自定义Provider

论文提出新颖的混合网络用于解决长尾图片分类问题,该网络由用于图像特征学习的对比学习分支和用于分类器学习的交叉熵分支组成,在训练过程逐步将训练权重调整至分类器学习,达到更好的特征得出更好的分类器的思想。另外,为了节省内存消耗,论文提出原型有监督对比学习。从实验结果来看,论文提出的方法效果还是很不错的,值得一看

来源:晓飞的算法工程笔记 公众号

论文: Contrastive Learning based Hybrid Networks for Long-Tailed Image Classification

Introduction


在实际场景中,图片类别通常都会呈现长尾分布,不常见的类别通常由于数据不足而无法被充分学习,给分类器的学习带来巨大的挑战。当前大多研究都通过减轻尾部类别的数据短缺来应对数据不平衡的问题,防止模型被头部类别控制,如数据重采样和数据增强等。
最近,有新的研究提出将长尾数据分类问题分解为特征学习和分类器学习两个阶段,认为这两个阶段适用不同的数据采样策略进行学习,比如随机采样更适合特征学习,而类别平衡采样更适合分类器学习。

但有一点需要注意的是,上述两类研究都没有考虑到,在数据不平衡场景下,交叉熵损失是否仍为特征学习的理想损失函数。交叉熵损失学习到的特征分布可能会高度倾斜,如上图所示,导致分类器存在偏向性,会影响长尾分类。
为此,论文研究了高效的对比学习策略,将其适配到不平衡数据中学习特征表达,提高长尾图片分类场景的性能。论文采用了新颖的混合网络结构,由用于特征表达学习的对比损失和用于分类器学习的交叉熵损失组成。两个损失联合训练,在训练过程中逐渐调整两个损失的权重,从特征学习逐步转移为分类器学习,遵循更好的特征产生更好的分类器的思想。

论文一开始采用从无监督对比(UC)中延伸出来的有监督对比(SC)损失用于特征学习,该损失使用batch内的样本进行相互对比,通过区分负样本来优化正样本间的一致性,如图左所示。如果想要保证优化效果,需要确保对比的正样本够多以及负样本覆盖足够多的类别,通常需要使用较大的batch,导致内存消耗过多。为了解决这个问题,论文提出了原型有监督对比(PSC)学习策略,从batch内的样本间对比改为batch内的样本与额外维护的原型进行对比,如图右所示。在保持原本有监督对比的特性的情况下,原型有监督对比避免了过多的内存消耗,还能使数据采样更灵活和高效。
论文的主要贡献如下:

  • 提出用于长尾数据分类的混合网络结构,由用于特征表达学习的对比损失和用于分类器学习的交叉熵损失组成。在训练过程中逐渐调整两个损失的权重,从特征学习逐步转移为分类器学习,遵循更好的特征产生更好的分类器的思想。
  • 研究高效的有监督对比学习策略用于更优的特征学习,提高长尾分类性能。另外,论文提出原型有监督对比来解决标准有监督对比的内存问题。
  • 验证在长尾分类场景中,有监督对比学习能更好地替代交叉熵损失进行特征学习。得益于学习到更好的特征,论文提出的混合网络能够极大地超越基于交叉熵的网络。

Contrastive learning


Unsupervised contrastive

无监督对比学习在无标签的场景下,通过同源图片与非同源图片之间的特征对比来进行特征表达的学习。比如先随机选取n张原图片,经过数据增强后变成2n张图片组成batch,将同源副本相互认为正样本、非同源副本认为负样本进行距离学习。

Supervised contrastive

有监督对比学习主在有标签的场景下,通过同类别图片与非同类别图片之间的特征对比来进行特征表达的学习。有监督对比学习也是需要进行数据增强生成同源副本的,所以正样本包含同源副本和同类别副本。比如选取n张原图片,经过数据增强后变成2n张图片组成batch,将同类图片相互认为正样本、非同类图片认为负样本进行距离学习。这里的n张图片选取不能随机选,为达到有监督的目的,同类别图片要大于1张。

Main Approach


A Hybrid Framework for Long-tailed Classification

论文提出的用于长尾图像分类的混合框架如上图所示,包含两个分支:

  • 用于图像特征学习的对比学习分支,构造同类内聚、异类分离的特征空间。
  • 用于分类器学习的交叉熵分支,基于对比学习分支得到的显著特征学习类别偏向较少的分类器。

为了达到用更好的特征帮助分类器进行学习,从而得到更通用的分类器的目的。论文参考了BBN的双分支联合训练方法,在训练阶段逐步调整这两个分支的权重。在训练初期以特征学习作为主导,随着训练的进行,分类器学习逐级主导训练。
主干网络在分支间共享,共同帮助主干网络学习每个图片的特征
\(r\in\mathcal{R}^{D_E}\)
。两个分支分别进行不同的操作:

  • 对比学习分支先通过MLP层
    \(f_e(\cdot)\)
    将图片特征
    \(r\)
    映射成向量表达
    \(z\in\mathcal{R}^{D_S}\)
    ,适配后续对比损失函数的计算。另外,这样的特征向量化转换也有助于提升前一层的特征质量。随后,对特征
    \(z\)
    进行
    \(\mathcal{l}_2\)
    归一化,使其能够用于距离计算。最后,使用输出的归一化特征计算有监督对比损失
    \(\mathcal{L}_{SCL}\)
  • 分类器学习分支先通过单个线性层从图像特征
    \(r\)
    预测类别结果
    \(s\in\mathcal{R}^{D_C}\)
    ,随后直接计算交叉熵损失
    \(\mathcal{L}_{CE}\)

需要注意的是,为了适应其损失函数的特性,两个分支的数据采样方式是不同的。特征学习分支需要附带样本
\(x_i\)
的同类正样本
\(\{x^{+}_i\}=\{x_j|y_i=y_j,i\ne j\}\)
和异类负样本
\(\{x^{-}_i\}=\{x_j|y_i=y_j,i\ne j\}\)
,组成单个batch输入
\(\mathcal{B}_{SC}=\{x_i, \{x^{+}_i\}, \{x^{-}_i\}\}\)
,而分类器学习分支则直接输入图片和标签
\(\mathcal{B}_{CE}=\{\{x_i, y_i\}\}\)
即可。
混合网络的最终损失函数为:

\(\alpha\)
是权重因子,与周期数成反相关。

Supervised contrastive loss and its memory issue

有监督对比损失(supervised contrastive loss, SC loss)是对无监督对比损失(unsupervised contrastive loss, UC loss)的扩展,区别在于单batch内的正负样本构成。假设目标图片的正负样本的向量特征为
\(\{z^{+}_i\}\)

\(\{z^{-}_i\}\)
,对于大小为N的minibatch,SC loss的计算为:

相对于UC loss,SC loss可采用任意数量的正样本。由于对比损失是通过区分负样本来优化正样本间的一致性,所以负样本数量十分重要的,而SC损失加入同类图片作为正样本,为保证负样本数量而不得不成倍地增加batch大小,导致内存消耗成倍地增加,导致内存消耗的成倍地增加,限制了SC loss的使用场景。
一个解决内存消耗的做法就是缩小负样本数量,但这样在类别数多的场景下会有问题。负样本数小意味着只能采样到少量负样本类别,肯定会影响学到的特征质量。

Prototypical supervised contrastive loss

为了同时兼顾内存消耗和特征质量,论文提出了原型有监督对比损失(prototypical supervised contrastive loss, PSC loss),为每个类别学习一个原型,强迫每个图片的数据增强副本尽量靠近其所属类别的原型以及远离其他类别的原型。使用原型有两个好处:1)允许更灵活的数据采样方式,不再需要显示地控制正负样本,可使用随机采样或类别平衡采样。2)数据采样更高效,假设有
\(\mathcal{C}\)
类别,则每次采样保证都有
\(\mathcal{C}-1\)
个负样本,这对于类别多的数据集特别重要。
PSC loss的计算如下:

\(p_{ij}\)
是类别
\(y_i\)
的原型特征,归一化为
\(\mathcal{R}^{D_S}\)
下的单位超球面,即满足L2归一化。这里没有提到原型是如何初始化和学习的,需要等源码放出来再看看。
PSC loss也可以延伸为每个类别多个原型,主要为了迎合单类别可能存在有多种数据分布的情况。多原型有监督对比损失(multiple prototype supervised contrastive loss, MPSC loss)的计算为:

\(M\)
为每个类别的原型数,
\(p^i_j\)
为类别j的第
\(i\)
个原型,
\(w_{i,k}(w_{i,k}\ge 0,{\sum}^M_{k=1})w_{i,k}=1\)

\(z_i\)
与第
\(k\)
个原型之间的关系值,用于更细粒度地控制每个样本,这将会在未来的工作中进行进一步地验证。

Experiment


Datasets

论文主要在三个长尾图片分类数据集进行实验:

  • Long-tailed CIFAR-10和CIFAR-100:原版的CIFAR数据集是平衡的,通过减少每个类别的图片数来生成长尾版本,注意验证集不变。用一个不平衡比例
    \(\beta=N_{max}/N_{min}\)
    来表示生成的长尾数据集的不平衡程度。
  • iNaturalist 2018:iNaturalist 2018是一个大型的生物品种数据集,包含8142个品种、437513张训练图片以及24424张验证图片。

Implementation details

对于长尾CIFAR数据集和iNaturalist数据集,论文使用了不同的实验配置:

  • Implementation details for long-tailed CIFAR:混合网络使用ResNet-32作为主干,两个分支共享的数据增强方法有:
    \(32\times 32\)
    的随机裁剪、水平翻转以及概率为0.2的随机灰度。另外,PSC loss也跟随SC loss使用额外的数据增强方法。在实验中,论文简单地使用有颜色扰动和无颜色扰动的图片作为数据增强副本对,batch size为512,使用momentum=0.9、weight decay=
    \(1\times 10^{-4}\)
    的SGD优化器。网络共训练200个周期,学习率初始为0.5并在第120周期和160周期下降10倍。权重因子
    \(\alpha=1-(T/T_{max})^2\)
    与周期数成抛物线衰减。对于SC loss,公式3的
    \(\tau\)
    固定为0.1,而对于PSC loss,在CIFAR-10和CIFAR-100上分别设置为1和0.1。
  • Implementation details for iNaturalist 2018:混合网络使用ResNet-50作为主干网络,数据增强跟长尾CIFAR一样,只是随机裁剪的图片大小为
    \(224\times 224\)
    ,batch size为100。网络共训练100轮,使用momentum=0.9、weight decay=
    \(1\times 10^{-4}\)
    的SGD优化器,学习率初始为0.05并在第60周期和第80周期下降10倍。考虑这个数据集的类别多,学习器训练较难,权重因子
    \(\alpha=1-T/T_{max}\)
    设置为线性下降,公式3的
    \(\tau\)
    固定为0.1。对于SC loss,正样本数固定为2。

Result

长尾CIFAR上的结果对比。

iNaturalist 2018上的结果对比。

Conclusion


论文提出新颖的混合网络用于解决长尾图片分类问题,该网络由用于图像特征学习的对比学习分支和用于分类器学习的交叉熵分支组成,在训练过程逐步将训练权重从特征学习调整至分类器学习,遵循更好的特征可得出更好的分类器的思想。另外,为了节省内存消耗,论文提出原型有监督对比学习。从实验结果来看,论文提出的方法效果还是很不错的,值得一看。

参考内容



如果本文对你有帮助,麻烦点个赞或在看呗~
更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.